diff --git a/.docker/Dockerfile b/.docker/Dockerfile new file mode 100644 index 00000000..f27f3e46 --- /dev/null +++ b/.docker/Dockerfile @@ -0,0 +1,23 @@ +# BUILD +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +ENV CI=true + +COPY docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/*.csproj CodeBeam.Website/ +COPY docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/*.csproj CodeBeam.Website.Client/ + +RUN dotnet restore CodeBeam.Website/CodeBeam.UltimateAuth.Docs.Wasm.csproj + +COPY . . + +RUN dotnet publish docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.csproj \ + -c Release -o /app/publish + +# RUNTIME +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/publish . + +EXPOSE 8080 +ENTRYPOINT ["dotnet", "CodeBeam.UltimateAuth.Docs.Wasm.dll"] diff --git a/.docker/docker-compose.yml b/.docker/docker-compose.yml new file mode 100644 index 00000000..2240fa4c --- /dev/null +++ b/.docker/docker-compose.yml @@ -0,0 +1,15 @@ +services: + ultimateauth: + build: + context: .. + dockerfile: .docker/Dockerfile + container_name: ultimateauth + restart: always + ports: + - "8081:8080" + networks: + - edge + +networks: + edge: + external: true diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000..6e4c378a --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: CodeBeamOrg +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..9b9a7537 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,39 @@ +--- +name: "๐Ÿž Bug Report" +about: Report a reproducible issue to help us improve UltimateAuth +title: "[Bug]: " +labels: bug +assignees: "" +--- + +## ๐Ÿž Bug Description +A clear and concise description of the issue. + + + +## ๐Ÿ“Œ Steps to Reproduce +1. Go to '...' +2. Call method '...' +3. Observe behavior '...' + + + +## ๐Ÿงช Expected Behavior +What should have happened? + + +## ๐Ÿ“ท Screenshots / Logs (if applicable) +Paste stack traces, console logs, or screenshots. + + + +## ๐Ÿงฉ Environment +- UltimateAuth version: +- .NET version: +- Platform: (Blazor / MAUI / ASP.NET Core / Other) +- OS: + + + +## โœ” Additional Context +Add any other relevant context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/design_proposal.md b/.github/ISSUE_TEMPLATE/design_proposal.md new file mode 100644 index 00000000..128f0df2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/design_proposal.md @@ -0,0 +1,45 @@ +--- +name: "๐Ÿง  Design Proposal" +about: Propose a high-level design for a new component, flow, or architectural change +title: "[Design]: " +labels: design +assignees: "" +--- + +## ๐Ÿง  Summary +Briefly describe the concept or design you're proposing. + +--- + +## ๐ŸŽฏ Goals +What problem does this design solve? +What are the objectives? + +--- + +## ๐Ÿงฉ Proposed Architecture +Describe how this feature or change should work: +- Components involved +- Flow diagrams (optional) +- Key abstractions +- Input/output expectations + +--- + +## ๐Ÿงช Alternatives Considered +List alternative designs and why they were not chosen. + +--- + +## ๐Ÿ”ฅ Risks / Trade-offs +Any potential drawbacks or complexities? + +--- + +## ๐Ÿ”— Related Issues +(Optional) Provide related bugs, features, or discussions. + +--- + +## โœ” Additional Notes +Add anything else that may help the maintainers evaluate this proposal. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..7a4cdb6c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,33 @@ +--- +name: "โœจ Feature Request" +about: Suggest a new idea or enhancement for UltimateAuth +title: "[Feature]: " +labels: enhancement +assignees: "" +--- + +## โœจ Feature Description +Describe the feature you'd like to see. + +--- + +## ๐Ÿ’ก Why Is This Needed? +Explain the problem this feature solves or the value it provides. + +--- + +## ๐Ÿ›  Suggested Implementation +If you have any implementation ideas, describe them: +- Proposed API shape +- Example usage +- Integration points + +--- + +## ๐Ÿ”— Related Issues / Discussions +(Optional) Link any related issues or design proposals. + +--- + +## โœ” Additional Notes +Anything else we should know? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..37f752d9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ +# ๐Ÿš€ Pull Request + +Thank you for contributing to **UltimateAuth**! +Please complete the following checklist to help us review your PR effectively. + +--- + +## ๐Ÿ“˜ Summary +Describe what this PR does and why itโ€™s needed. + +--- + +## ๐Ÿ” Details +Explain any important implementation details, design decisions, or considerations. + +--- + +## ๐Ÿงฉ Related Issues +Link any related issues: + + +--- + +## ๐Ÿ›  Changes +- [ ] New feature +- [ ] Bug fix +- [ ] Documentation +- [ ] Refactoring +- [ ] Breaking change + +--- + +## โœ” Checklist +- [ ] Iโ€™ve tested my changes +- [ ] Iโ€™ve added comments or documentation where needed +- [ ] Iโ€™ve followed the projectโ€™s coding conventions +- [ ] Iโ€™ve validated that the API surface is stable diff --git a/.github/codecov.yml b/.github/codecov.yml new file mode 100644 index 00000000..70dbff6f --- /dev/null +++ b/.github/codecov.yml @@ -0,0 +1,13 @@ +comment: + require_changes: yes + +coverage: + status: + project: + default: + target: 50% + threshold: 5% + patch: + default: + target: 20% + threshold: 0% diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..ff7915fd --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: Deploy UltimateAuth + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + script: | + cd ~/apps/UltimateAuth + git pull origin master + cd .docker + docker compose down + docker compose up -d --build + diff --git a/.github/workflows/ultimateauth-ci.yml b/.github/workflows/ultimateauth-ci.yml new file mode 100644 index 00000000..021000ae --- /dev/null +++ b/.github/workflows/ultimateauth-ci.yml @@ -0,0 +1,46 @@ +name: UltimateAuth CI + +on: + push: + branches: [ dev ] + pull_request: + branches: [ dev ] + workflow_dispatch: + +jobs: + build-test-coverage: + name: Build, Test & Coverage + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + dotnet-version: ['8.0.x', '9.0.x', '10.0.x'] + + steps: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@v4 + + - name: ๐Ÿงฐ Setup .NET SDK ${{ matrix.dotnet-version }} + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ matrix.dotnet-version }} + + - name: ๐Ÿ“ฆ Restore dependencies + run: dotnet restore + + - name: ๐Ÿ—๏ธ Build + run: dotnet build --configuration Release --no-restore + + - name: ๐Ÿงช Test with coverage + run: | + dotnet test \ + --configuration Release \ + --no-build \ + --collect:"XPlat Code Coverage" + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: CodeBeamOrg/UltimateAuth diff --git a/.gitignore b/.gitignore index 9491a2fd..6786d1dc 100644 --- a/.gitignore +++ b/.gitignore @@ -194,6 +194,7 @@ PublishScripts/ # NuGet Packages *.nupkg +nupkgs/ # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore diff --git a/.ultimateauth/ai-guardrails.md b/.ultimateauth/ai-guardrails.md new file mode 100644 index 00000000..3337aade --- /dev/null +++ b/.ultimateauth/ai-guardrails.md @@ -0,0 +1,118 @@ +# UltimateAuth AI Guardrails + +This document defines mandatory guardrails for AI-assisted tools operating on the UltimateAuth codebase. +These rules are NON-NEGOTIABLE. + +--- + +## 1. Canonical Authority + +- `.ultimateauth/architecture.md` is the single canonical source of architectural truth. +- AI tools MUST read and respect `architecture.md` before making any change. +- If a change conflicts with `architecture.md`, the change MUST NOT be made. + +--- + +## 2. Forbidden Refactors + +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. +- Introduce client-side authority over identity or access. + +--- + +## 3. Context Integrity Rules + +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. + +Context objects define security boundaries. + +--- + +## 4. Orchestration and Authority Rules + +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 + +All such operations MUST pass through an orchestrator and an authority component. + +--- + +## 5. Session and Token Rules + +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. + +Sessions are server-authoritative. + +--- + +## 6. Domain Boundary Rules + +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. + +UserKey is an opaque cross-domain identity anchor. + +--- + +## 7. Client and Runtime Rules + +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. + +Client SDKs are adapters, not security authorities. + +--- + +## 8. Extensibility Safety Rules + +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. + +Extensibility MUST preserve all security guarantees. + +--- + +## 9. Final Enforcement Rule + +If an AI tool is uncertain whether a change violates these guardrails, the change MUST NOT be made. + +Security correctness takes precedence over convenience, +performance, or refactoring elegance. diff --git a/.ultimateauth/architecture.md b/.ultimateauth/architecture.md new file mode 100644 index 00000000..393e820d --- /dev/null +++ b/.ultimateauth/architecture.md @@ -0,0 +1,834 @@ +# UltimateAuth Architecture (Canonical) + +This document defines the NON-NEGOTIABLE architectural principles of UltimateAuth. + +If any code, documentation, or contribution conflicts with this document, the code is considered incorrect. + +This document is intentionally concise. Explanations, examples, and extended discussions belong in separate architecture guides. + +## 1. Architectural Scope + +This document defines the canonical architectural boundaries and non-negotiable rules of the UltimateAuth framework. +The scope of this document is strictly limited to the authentication and authorization architecture of UltimateAuth. + +### 1.1 What This Document Defines + +This document defines: + +- The core authentication model and its invariants. +- The request pipeline and context model used to derive authentication and authorization state. +- The separation of responsibilities between core layers (Store, Service, Authority, Issuer). +- The boundaries and responsibilities of plugin domains (Users, Credentials, Authorization). +- The session architecture and its role as the primary source of authentication truth. +- The client and runtime interaction model as it relates to authentication flows. +- Security invariants that MUST hold across all implementations. + +These rules apply to all UltimateAuth components, including Core, Server, Client, plugin domains, and reference implementations. + +--- + +### 1.2 What This Document Does NOT Define + +This document explicitly does NOT define: + +- User interface or user experience flows. +- Application-specific business rules. +- Hosting, deployment, or infrastructure concerns. +- Persistence technologies or storage engines. +- Network protocols or transport-level optimizations. +- Product roadmap, feature prioritization, or timelines. +- Documentation structure or tutorial content. + +Any concern outside authentication and authorization architecture is considered out of scope. + +--- + +### 1.3 Architectural Authority + +This document is the single authoritative source for architectural decisions within UltimateAuth. + +If any implementation, documentation, contribution, or automated refactoring conflicts with this document, the implementation is considered incorrect. + +Convenience, performance optimizations, or stylistic preferences MUST NOT override the rules defined here. + +--- + +### 1.4 Relationship to Other Documents + +- The UltimateAuth Manifesto describes the philosophy, intent, and design values of the framework. +- This document translates those values into concrete, enforceable architectural rules. +- Architecture guides and design documents MAY provide explanations or examples, but MUST NOT redefine or contradict this document. + +In case of conflict, this document takes precedence. + +--- + +### 1.5 Intended Audience + +This document is intended for: + +- Core framework maintainers. +- Contributors modifying authentication-related code. +- Reviewers evaluating architectural correctness. +- Automated tools (including AI-assisted refactoring) operating on the UltimateAuth codebase. + +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. + +--- + +### 2.1 Session as the Source of Truth + +In UltimateAuth, sessions are the primary and authoritative representation of authentication state. + +- 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. + +Any authentication model that treats tokens as the primary source of identity is explicitly rejected. + +--- + +### 2.2 Authentication Modes + +UltimateAuth supports multiple authentication modes to address different deployment 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. + +- **Hybrid** + Session-backed authentication with client-readable tokens used as a performance optimization. + +- **SemiHybrid** + Session-backed authentication with limited client-side identity representation. + +- **PureJwt** + Token-centric authentication with server-side validation, used only when session-backed models are not feasible. + +Authentication modes define *how authentication state is represented and transported*, not *how identity is verified*. + +The selected authentication mode MUST NOT alter core authentication semantics. + +--- + +### 2.3 Client Profiles and Runtime Awareness + +UltimateAuth operates across multiple client runtimes (Blazor Server, Blazor WebAssembly, MAUI, MVC, APIs). + +Authentication behavior is adapted at runtime using Client Profiles. + +- Client Profiles define runtime-specific defaults. +- Client Profiles are automatically detected when possible. +- Runtime-specific behavior MUST NOT require separate authentication models. + +All clients participate in the same core authentication model regardless of runtime or platform. + +--- + +### 2.4 Request-Scoped Authentication Evaluation + +Authentication state is evaluated per request. + +- Each request derives its authentication context independently. +- Authentication evaluation is deterministic and repeatable. +- No implicit or hidden authentication state is allowed. + +Request-based evaluation ensures consistent behavior across distributed systems, retries, and concurrent requests. + +--- + +### 2.5 Separation of Authentication and Authorization + +Authentication and authorization are strictly separated. + +- Authentication establishes identity. +- Authorization evaluates permissions and access decisions. + +Authentication MUST NOT embed authorization logic, permission checks, or policy decisions. + +Authorization systems MUST rely on authenticated identity provided by the authentication model. + +--- + +### 2.6 Extensibility Without Semantic Drift + +The core authentication model is extensible through plugin domains and override points. + +Extensibility MUST preserve the semantic guarantees defined in this section. + +Custom implementations, alternative storage mechanisms, or runtime-specific optimizations MUST NOT change: + +- The session-first nature of authentication. +- The server-authoritative identity model. +- The separation between authentication and authorization. + +## 3. Request Pipeline & Context Model + +UltimateAuth evaluates authentication and authorization state within a well-defined, deterministic request pipeline. +This pipeline is responsible for producing immutable context objects that represent the authentication and authorization boundaries of a request. + +--- + +### 3.1 AuthFlowContext + +AuthFlowContext represents the complete authentication flow state of a request. + +- 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. + +AuthFlowContext encapsulates all information required to evaluate authentication state, including session data, client characteristics, and runtime-specific signals. + +AuthFlowContext MUST NOT be modified, replaced, or reconstructed after initial creation. + +--- + +### 3.2 AccessContext + +AccessContext represents the authorization boundary derived from authentication state. + +- AccessContext is derived from AuthFlowContext. +- AccessContext defines *who* the current actor is and *what context* the request is operating under. +- AccessContext is used exclusively for authorization, policy evaluation, and permission checks. + +AccessContext MUST NOT contain authentication logic. +Authentication decisions MUST be resolved before AccessContext is created. + +--- + +### 3.3 Context Creation Boundaries + +Context creation is a controlled operation within the request 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. + +Application code, services, and stores MUST treat AuthFlowContext and AccessContext as read-only inputs. + +--- + +### 3.4 Request Determinism + +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. + +Deterministic evaluation ensures predictable behavior across distributed systems and asynchronous execution. + +--- + +### 3.5 Pipeline Integration Model + +UltimateAuth integrates with host frameworks through explicit pipeline extension points. + +- Authentication state is established before endpoint execution. +- Context creation occurs as part of the request pipeline, not within application logic. +- Application endpoints MUST NOT be responsible for constructing or mutating authentication context. + +The exact integration mechanism (e.g. middleware, endpoint filters, or framework-specific hooks) is an implementation detail and MUST preserve the semantic guarantees defined in this section. + +--- + +### 3.6 Architectural Invariants + +The following invariants MUST hold for all implementations: + +- 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. + +Any implementation that violates these invariants is considered architecturally incorrect. + +## 4. Domain Boundaries + +UltimateAuth is composed of clearly separated domains with explicit responsibilities and non-overlapping concerns. +Domains define behavioral and security boundaries. They MUST NOT be merged, partially implemented, or implicitly coupled. + +--- + +### 4.1 User-Centric Domains + +User-related concerns are intentionally split into multiple independent domains. + +The following domains exist: + +- **UserLifecycle** + Represents user existence and security-relevant state (e.g. active, disabled, deleted). + +- **UserProfile** + Represents user-facing profile and presentation data. This domain MUST NOT affect authentication decisions. + +- **UserIdentifier** + Represents login identifiers (e.g. email, phone, username) and their verification lifecycle. + This domain does NOT contain secrets or credentials. + +Each domain has its own lifecycle, persistence model and invariants. + +No domain may directly modify the state of another domain. + +--- + +### 4.2 Credentials Domain + +The Credentials domain is responsible for secret material used to prove identity. + +- Credentials are not user profiles. +- Credentials are not identifiers. +- Credentials are security-critical and isolated by design. + +Credential types (e.g. password, passkey, OTP) are modeled as distinct domain concepts, independent of storage layout. + +--- + +### 4.3 Authorization Domain + +The Authorization domain evaluates permissions and access decisions based on authenticated identity. + +- Authorization depends on authentication state. +- Authentication MUST NOT depend on authorization. +- Authorization logic MUST NOT leak into other domains. + +--- + +### 4.4 UserKey as a Cross-Domain Identity Anchor + +All user-related domains are linked through a shared UserKey. + +UserKey is a value object that represents a stable, opaque identity anchor across domains. + +- UserKey is NOT a domain itself. +- UserKey does NOT represent persistence identity. +- UserKey does NOT expose internal structure to domains. +- Domains MUST treat UserKey as an opaque identifier. + +Mapping between UserKey and application-specific user identifiers occurs exclusively at system boundaries. + +The internal representation of UserKey MUST remain flexible and replaceable without affecting domain logic. + +--- + +### 4.5 Domain Independence Guarantees + +The following guarantees MUST hold: + +- Domains share UserKey but do NOT share state. +- Domain lifecycles are independent. +- Persistence concerns MUST NOT redefine domain boundaries. +- Cross-domain operations MUST be coordinated at the service or orchestration layer. + +Violating domain boundaries is considered an architectural error. + +## 5. Store, Service, Orchestrator, and Authority Separation + +UltimateAuth enforces a strict separation of responsibilities between persistence, application coordination, orchestration and security decision-making. +Each layer has explicit constraints and MUST NOT assume responsibilities belonging to another layer. + +--- + +### 5.1 Stores + +Stores are persistence-only components. + +- Stores handle data access and persistence. +- Stores MUST NOT contain authorization logic. +- Stores MUST NOT evaluate policies or permissions. +- Stores MUST NOT create or modify authentication or authorization context. +- Stores MUST NOT depend on AccessContext or AuthFlowContext. + +Stores are deterministic and side-effect free beyond their persistence responsibility. + +--- + +### 5.2 Services + +Services represent application-level use cases. + +- Services define *what* operation is being performed. +- Services coordinate high-level workflows. +- Services invoke orchestrators to execute security-sensitive operations. + +Services MUST NOT bypass orchestrators or authorities. + +Services are not security boundaries. + +--- + +### 5.3 Orchestrators + +Orchestrators coordinate complex, security-critical flows +across multiple domains and subsystems. + +- Orchestrators are policy-aware. +- Orchestrators enforce sequencing, invariants and cross-domain consistency. +- Orchestrators interact with Authority components to evaluate security decisions. + +UltimateAuth defines multiple orchestrators, including but not limited to: + +- Session Orchestrator +- Access Orchestrator +- Login Orchestrator + +The existence of multiple orchestrators is intentional. +New orchestrators MAY be introduced as the system evolves. + +--- + +### 5.4 Authority Components + +Authority components are responsible for making security and authorization decisions. + +- Authorities evaluate policies and permissions. +- Authorities validate whether an operation is allowed. +- Authorities are the final decision point for security-sensitive actions. + +Authority logic MUST NOT be embedded in services, stores or domain models. + +--- + +### 5.5 Mandatory Orchestration Rule + +All security-relevant operations MUST pass through an orchestrator and an authority. + +No operation that affects authentication state, authorization decisions, session validity, credentials, or user security state may be executed without explicit orchestration and authority evaluation. + +Bypassing orchestrators or authorities is considered a critical architectural violation. + +--- + +### 5.6 Architectural Guarantees + +The following guarantees MUST hold: + +- Stores are never policy-aware. +- Services never perform security decisions directly. +- Orchestrators always coordinate security-sensitive flows. +- Authorities are the single source of truth for authorization decisions. +- No execution path may bypass orchestrators and authorities. + +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. + +Sessions establish continuity, revocation guarantees, and server-side control over authentication state. + +--- + +### 6.1 Session as an Authentication Primitive + +A session represents an authenticated identity and its associated security state. + +- Sessions are server-owned and server-validated. +- Sessions define authentication continuity across requests. +- Sessions are the authoritative source of authentication truth. + +Tokens, cookies, or other client-held artifacts are transport mechanisms and MUST NOT be treated as identity sources. + +--- + +### 6.2 Session Types and Composition + +UltimateAuth supports structured session composition. + +- **Root Sessions** represent the primary authenticated identity. +- **Chained Sessions** represent derived or delegated authentication contexts. + +Chained sessions MUST be traceable to a root session and MUST NOT exist independently. + +Session composition enables controlled delegation, refresh flows, and security isolation without duplicating identity state. + +--- + +### 6.3 Session Validation and Resolution + +Session validation is performed on every request that requires authentication. + +- Session validity MUST be verified server-side. +- Session resolution MUST be deterministic. +- Cached or inferred session state is not permitted. + +Session resolution MUST NOT depend solely on client-held data. + +--- + +### 6.4 Revocation and Invalidation Semantics + +Session revocation is a first-class security operation. + +- Revoked sessions MUST be rejected immediately. +- Revocation MUST be enforceable across all authentication modes. +- Session invalidation MUST propagate deterministically. + +Eventual or best-effort revocation semantics are explicitly rejected. + +--- + +### 6.5 Session Refresh and Continuity + +Session refresh preserves authentication continuity without re-authentication. + +- Refresh operations MUST validate the underlying session. +- Refresh MUST NOT silently elevate privileges. +- Refresh behavior MUST respect the active authentication mode. + +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. + +- 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. + +Session architecture remains consistent across all modes. + +--- + +### 6.7 Security Invariants + +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. + +Any implementation that violates these invariants is considered architecturally incorrect. + +## 7. Client & Runtime Model + +UltimateAuth defines a single, unified authentication model that operates consistently across multiple client runtimes. +Client runtimes influence *how* authentication flows are executed, but MUST NOT redefine authentication semantics. + +--- + +### 7.1 Runtime-Agnostic Core + +The UltimateAuth core authentication model is runtime-agnostic. + +- Authentication semantics are defined on the server. +- Client runtimes do not own identity or authentication state. +- Runtime differences MUST NOT result in divergent authentication models. + +All clients participate in the same authentication and session architecture regardless of platform. + +--- + +### 7.2 Supported Client Runtimes + +UltimateAuth supports multiple client runtimes, including +but not limited to: + +- Blazor Server +- Blazor WebAssembly +- MAUI +- MVC applications +- API and headless clients + +Support for multiple runtimes is achieved through adaptation, not duplication of authentication logic. + +--- + +### 7.3 Client Profiles + +Runtime-specific behavior is expressed through Client Profiles. + +- Client Profiles define runtime-appropriate defaults. +- Client Profiles are automatically detected when possible. +- Client Profiles MAY be explicitly configured when required. + +Client Profiles influence transport mechanisms, flow selection, and security constraints, but MUST NOT change core authentication semantics. + +--- + +### 7.4 Request-Based Client Participation + +Clients participate in authentication on a per-request basis. + +- Each request is evaluated independently. +- Client-provided data is treated as input, not authority. +- Authentication state is resolved server-side for every request. + +No client runtime is permitted to cache or infer authentication authority outside server validation. + +--- + +### 7.5 Public Clients and PKCE Requirements + +Public clients (including browser-based and mobile clients) are treated as untrusted environments. + +- Public clients MUST NOT hold secrets. +- PKCE is REQUIRED for authorization flows involving public clients. +- Authentication flows MUST assume client compromise is possible. + +Security guarantees MUST be preserved even in the presence of malicious or compromised clients. + +--- + +### 7.6 Client SDK Responsibilities + +Client SDKs and libraries provide convenience and integration support only. + +- Client SDKs MUST NOT create identity. +- Client SDKs MUST NOT evaluate authorization decisions. +- Client SDKs MUST NOT bypass authentication or session validation. + +Client SDKs are adapters, not security authorities. + +--- + +### 7.7 Cross-Runtime Consistency Guarantees + +The following guarantees MUST hold across all runtimes: + +- Authentication semantics are identical across clients. +- Session validation remains server-authoritative. +- Revocation behavior is consistent across runtimes. +- Runtime-specific optimizations MUST NOT weaken security. + +Any implementation that introduces runtime-specific authentication semantics is considered architecturally incorrect. + +## 8. Security Invariants + +The following security invariants define the non-negotiable security guarantees of UltimateAuth. +These invariants apply across all authentication modes, client runtimes, domains and implementations. +Violating any invariant compromises system security and is considered architecturally incorrect. + +--- + +### 8.1 Server Authority Invariant + +The server is the sole authority for authentication and authorization decisions. + +- Clients are never trusted authorities. +- Client-provided data is always treated as untrusted input. +- Authentication state MUST be validated server-side. + +No client runtime, SDK, or application code may assume authority over identity or access decisions. + +--- + +### 8.2 Session Authority Invariant + +Sessions are the single authoritative source of authenticated identity. + +- Authentication state MUST be session-backed. +- Tokens, cookies, or headers are transport artifacts only. +- Session validation MUST occur on every authenticated request. + +No authentication flow may bypass session validation. + +--- + +### 8.3 Context Integrity Invariant + +Authentication and authorization context objects define security boundaries. + +- AuthFlowContext is immutable after creation. +- Exactly one AuthFlowContext exists per request. +- AccessContext is derived from AuthFlowContext. +- Context objects MUST NOT be mutated, recreated or bypassed. + +Context integrity is mandatory for deterministic and secure request processing. + +--- + +### 8.4 Orchestration Invariant + +All security-relevant operations MUST be orchestrated. + +- 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. + +Bypassing orchestration or authority evaluation is explicitly forbidden. + +--- + +### 8.5 Domain Isolation Invariant + +Domains represent isolated security and responsibility boundaries. + +- Domains MUST NOT share mutable state. +- Domains MUST NOT directly modify other domains. +- Cross-domain operations MUST be coordinated through services and orchestrators. + +Domain isolation MUST NOT be weakened by persistence or implementation convenience. + +--- + +### 8.6 Credential Protection Invariant + +Credential material is security-critical and requires strict isolation. + +- 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 leakage or reuse across domains is forbidden. + +--- + +### 8.7 Deterministic Evaluation Invariant + +Authentication and authorization evaluation MUST be deterministic. + +- Identical inputs MUST produce identical outcomes. +- Hidden, implicit, or ambient security state is not allowed. +- Concurrent or retried requests MUST behave consistently. + +Deterministic evaluation is required for correctness, auditability and security. + +--- + +### 8.8 Revocation Invariant + +Revocation is a first-class security operation. + +- 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. + +Revocation guarantees MUST NOT be weakened for performance or convenience. + +--- + +### 8.9 Extensibility Safety Invariant + +UltimateAuth is extensible, but extensibility MUST NOT alter security semantics. + +- Extensions MUST preserve all security invariants. +- Overrides MUST NOT bypass orchestration, authority or session validation. +- Custom implementations MUST remain server-authoritative. + +Extensibility that compromises security guarantees is not supported. + +## 9. What This Document Does NOT Define + +This document intentionally limits its scope to architectural rules and security invariants. +The following concerns are explicitly out of scope and MUST NOT be inferred from this document. + +--- + +### 9.1 User Experience and Application Flow + +This document does NOT define: + +- User interface design or layout. +- User experience flows. +- Screen navigation or interaction patterns. +- Application-specific onboarding or registration flows. + +Such concerns are application responsibilities and vary by use case. + +--- + +### 9.2 API Shapes and Public Contracts + +This document does NOT define: + +- Public API method signatures. +- DTO shapes or transport contracts. +- Client SDK APIs or surface area. +- HTTP endpoint structures or routing conventions. + +API design MAY evolve as long as architectural and security rules are preserved. + +--- + +### 9.3 Persistence and Infrastructure + +This document does NOT define: + +- Database technologies or providers. +- Schema designs or table layouts. +- Caching strategies. +- Replication, sharding, or scaling approaches. +- Hosting or deployment topology. + +Persistence and infrastructure choices MUST NOT alter architectural or security guarantees. + +--- + +### 9.4 Performance Optimizations + +This document does NOT define: + +- Performance tuning strategies. +- Caching heuristics or TTL policies. +- Latency optimizations. +- Resource allocation strategies. + +Performance improvements MUST preserve all architectural and security invariants. + +--- + +### 9.5 Feature Set and Product Roadmap + +This document does NOT define: + +- Feature completeness. +- Supported scenarios. +- Roadmap priorities or timelines. +- Backward compatibility guarantees. + +Product direction is defined separately and MUST NOT override architectural constraints. + +--- + +### 9.6 Implementation Techniques + +This document does NOT define: + +- Framework-specific implementation patterns. +- Language-level constructs or idioms. +- Code organization or folder structure. +- Testing strategies or tooling. + +Implementation techniques are free to evolve within the boundaries defined by this document. + +--- + +### 9.7 Documentation and Educational Content + +This document does NOT define: + +- Tutorials or onboarding materials. +- Example applications. +- Reference guides or walkthroughs. + +Educational content MUST explain the architecture but MUST NOT redefine it. + +--- + +### 9.8 Final Authority Statement + +If a concern is not explicitly defined in this document, it is considered an implementation or product decision, not an architectural rule. + +This document exists to constrain behavior, not to describe every possible behavior. + + +--- + +## Change Policy + +This document is expected to change rarely. +Any change to this document MUST be intentional, explicit, and reviewed with extreme care. +Incremental refactors, convenience changes, or stylistic improvements MUST NOT modify the architectural rules defined here. diff --git a/.ultimateauth/pack.bat.txt b/.ultimateauth/pack.bat.txt new file mode 100644 index 00000000..fec6d194 --- /dev/null +++ b/.ultimateauth/pack.bat.txt @@ -0,0 +1,27 @@ +@echo off + +echo ============================== +echo Packing UltimateAuth packages +echo ============================== + +REM eski paketleri temizle +if exist nupkgs ( + echo Cleaning old packages... + rmdir /s /q nupkgs +) + +REM pack iลŸlemi +echo Running dotnet pack... +dotnet pack -c Release -o ./nupkgs + +REM sonuรง kontrol +if %errorlevel% neq 0 ( + echo โŒ Pack failed! + pause + exit /b %errorlevel% +) + +echo โœ… Pack completed successfully! +echo Packages are in /nupkgs + +pause \ No newline at end of file diff --git a/.ultimateauth/package.bat b/.ultimateauth/package.bat new file mode 100644 index 00000000..fec6d194 --- /dev/null +++ b/.ultimateauth/package.bat @@ -0,0 +1,27 @@ +@echo off + +echo ============================== +echo Packing UltimateAuth packages +echo ============================== + +REM eski paketleri temizle +if exist nupkgs ( + echo Cleaning old packages... + rmdir /s /q nupkgs +) + +REM pack iลŸlemi +echo Running dotnet pack... +dotnet pack -c Release -o ./nupkgs + +REM sonuรง kontrol +if %errorlevel% neq 0 ( + echo โŒ Pack failed! + pause + exit /b %errorlevel% +) + +echo โœ… Pack completed successfully! +echo Packages are in /nupkgs + +pause \ No newline at end of file diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..758c57e5 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,21 @@ + + + 0.1.0-preview.1 + $(NoWarn);1591 + + CodeBeam + CodeBeam + + https://github.com/CodeBeamOrg/UltimateAuth + https://github.com/CodeBeamOrg/UltimateAuth + Apache-2.0 + + true + enable + enable + + true + snupkg + true + + \ No newline at end of file diff --git a/README.md b/README.md index 22761bcc..b3221c5b 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,288 @@ -๏ปฟโš ๏ธ This project is in early development. Preview release expected Q1 2026. -# UltimateAuth -### The Modern Unified Auth Framework for .NET -- Reimagined. -A CodeBeam Project +UltimateAuth Banner + +

+ +![Build](https://github.com/CodeBeamOrg/UltimateAuth/actions/workflows/ultimateauth-ci.yml/badge.svg) +![GitHub stars](https://img.shields.io/github/stars/CodeBeamOrg/UltimateAuth?style=flat&logo=github) +![Last Commit](https://img.shields.io/github/last-commit/CodeBeamOrg/UltimateAuth?branch=dev&logo=github) +![License](https://img.shields.io/github/license/CodeBeamOrg/UltimateAuth) +[![Discord](https://img.shields.io/discord/1459498792192839774?color=%237289da&label=Discord&logo=discord&logoColor=%237289da&style=flat-square)](https://discord.gg/QscA86dXSR) +[![codecov](https://codecov.io/gh/CodeBeamOrg/UltimateAuth/branch/dev/graph/badge.svg)](https://codecov.io/gh/CodeBeamOrg/UltimateAuth) + +## ๐Ÿ“‘ Table of Contents + +- [๐Ÿ—บ Roadmap](#-roadmap) +- [๐ŸŒŸ Why UltimateAuth](#-why-ultimateauth) +- [๐Ÿš€ Quick Start](#-quick-start) +- [๐Ÿ’ก Usage](#-usage) +- [๐Ÿ“˜ Documentation](#-documentation) +- [๐Ÿค Contributing](#-contributing) +- [โญ Acknowledgements](#-acknowledgements) --- -UltimateAuth is an open-source authentication framework that unifies secure session and token based authentication, modern PKCE flows, Blazor/Maui-ready client experiences, and a fully extensible architecture โ€” all with a focus on clarity, lightweight design, and developer happiness. +UltimateAuth is an open-source auth framework with platform-level capabilities that unifies secure session, cookie and token based Auth, modern PKCE flows, Blazor/Maui-ready client experiences - eliminating the complexity of traditional Auth systems while providing a clean, lightweight, extensible and developer-first architecture. --- +## ๐Ÿ—บ Roadmap -## ๐ŸŒŸ Why UltimateAuth: The Six-Point Principles +| Phase | Version | Scope | Status | Release Date | +| ----------------------- | ------------- | ----------------------------------------- | -------------- | ------------ | +| First Preview | 0.1.0-preview | "Stable" Preview Core | โœ… Completed | 07.04.2026 | +| First Release* | 0.1.0 | Fully Documented & Quality Tested | ๐ŸŸก In Progress | Q2 2026 | +| Product Expansion | 0.2.0 | Full Auth Modes | ๐ŸŸก In Progress | Q2 2026 | +| Security Expansion | 0.3.0 | MFA, Reauth, Rate Limiting | ๐Ÿ”œ Planned | Q2 2026 | +| Infrastructure Expansion| 0.4.0 | Redis, Distributed Cache, Password Hasher | ๐Ÿ”œ Planned | Q2 2026 | +| Multi-Tenant Expansion | 0.5.0 | Multi tenant management | ๐Ÿ”œ Planned | Q3 2026 | +| Extensibility Expansion | 0.6.0 | Audit, events, hooks | ๐Ÿ”œ Planned | Q3 2026 | +| Performance Expansion | 0.7.0 | Benchmarks, caching | ๐Ÿ”œ Planned | Q3 2026 | +| Ecosystem Expansion | 0.8.0 | Migration tools | ๐Ÿ”œ Planned | Q4 2026 | +| v1.0 | 1.0.0 | Locked API, align with .NET 11 | ๐Ÿ”œ Planned | Q4 2026 | -### **1) Developer-Centric** -Clean APIs, predictable behavior, minimal ceremony โ€” designed to make authentication *pleasant* for developers. +*v 0.1.0 already provides a skeleton of multi tenancy, MFA, reauth etc. Expansion releases will enhance these areas. -### **2) Security-Driven** -PKCE, hardened session flows, reuse detection, event-driven safeguards, device awareness, and modern best practices. +> The project roadmap is actively maintained as a GitHub issue: -### **3) Extensible & Lightweight by Design** -Every component can be replaced or overridden. -No forced dependencies. No unnecessary weight. +๐Ÿ‘‰ https://github.com/CodeBeamOrg/UltimateAuth/issues/8 -### **4) Plug-and-Play Ready** -From setup to production, UltimateAuth prioritizes a frictionless integration journey with sensible defaults. +We keep it up-to-date with current priorities, planned features, and progress. Feel free to follow, comment, or contribute ideas. -### **5) User-Friendly Flows** -Authentication should be secure *and* intuitive. -Consistent, predictable, and UX-friendly at every step. +
-### **6) Blazor & MAUI-Ready for Modern .NET** -Blazor WebApp, Blazor WASM, Blazor Server, and .NET MAUI expose weaknesses in traditional auth systems. -UltimateAuth is engineered from day one to support real-world scenarios across the entire modern .NET UI stack. +> UltimateAuth is currently in the final stage of the first preview release (v 0.1.0-preview). + +> Core architecture is complete and validated through working samples. + +> Ongoing work: +> - Final API surface review +> - Developer experience improvements +> - EF Core integration polishing +> - Documentation refinement +
--- -## ๐Ÿ”‘ What UltimateAuth Provides +## ๐ŸŒŸ Why UltimateAuth +The Six-Point Principles -- A **secure, modern session-based authentication core** - (opaque SessionId, server-managed, real-time revocation, device tracking) +### 1) Unified Authentication System -- A **unified architecture** bridging Session, PKCE, and OAuth-style auth flows +One solution, one mental model โ€” across Blazor Server, WASM, MAUI, and APIs. +UltimateAuth eliminates fragmentation by handling client differences internally and exposing a single, consistent API. -- An **override-first design** suitable for enterprise extensions +### 2) Plug & Play Ready -- A **production-grade client SDK** for Blazor & MAUI +Built-in capabilities designed for real-world scenarios: -- A **fully interactive sandbox** - where developers can test flows, create accounts, simulate devices, and validate behaviors in real time +- Automatic client profile detection (blazor server - WASM - MAUI) +- Selectable authentication modes (Session / Token / Hybrid / SemiHybrid) +- Device-aware sessions +- PKCE flows out of the box +- Unified session + token lifecycle +- Event-driven extensibility ---- +No boilerplate. No hidden complexity. + +### 3) Developer-Centric + +Clean APIs, predictable behavior, minimal ceremony โ€” designed to make authentication pleasant. + +### 4) Security as a First-Class Concern + +Modern security built-in by default: + +- PKCE support +- Session reuse detection +- Device tracking +- Hardened auth flows +- Safe defaults with extensibility + +### 5) Extensible & Lightweight -## ๐Ÿ“… Release Timeline (Targeted) +Start simple, scale infinitely: -> _Dates reflect targeted milestones and may evolve with community feedback._ +- Works out of the box with sensible defaults +- Replace any component when needed +- No forced architecture decisions -### **Q1 2026 โ€” Preview (v 0.1.0)** -- Core session-based auth engine +### 6) Built for Modern .NET Applications -### **Q2 2026 โ€” Stable Feature Release** -- Token-based flows +Designed specifically for real-world .NET environments: -### **Q3 2026 โ€” v 1.0.0 (General Availability)** -- API surface locked -- Production-ready security hardening -- Unified architecture finalized +- Blazor Server +- Blazor WASM +- .NET MAUI +- Backend APIs -### **Q4 2026 โ€” v 11.x.x (.NET 11 Alignment Release)** -UltimateAuth adopts .NET platform versioning to align with the broader ecosystem. +Traditional auth solutions struggle here โ€” UltimateAuth embraces it. + +--- + +# ๐Ÿš€ Quick Start +> โฑ Takes ~2 minutes to get started + +### 1) Install packages (Will be available soon) + +1.1 Core Packages +```bash +dotnet add package CodeBeam.UltimateAuth.Server +dotnet add package CodeBeam.UltimateAuth.Client.Blazor +``` +1.2 Persistence & Reference Packages (Choose One) +```bash +dotnet add package CodeBeam.UltimateAuth.InMemory.Bundle (for debug & development) +dotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle (for production) +``` +### 2) Configure services (in program.cs) +Server registration: +```csharp +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthEntityFrameworkCore(db => + { + // use with your database provider + db.UseSqlite("Data Source=uauth.db"); + }); + +// OR + +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthInMemory(); // Development + +``` + +Client registration: + +```csharp +builder.Services.AddUltimateAuthClientBlazor(); +``` +**Usage by application type:** + +- **Blazor Server App** โ†’ Use both Server and Client registrations +- **Blazor WASM / MAUI** โ†’ Use Client only +- **Auth Server / Resource API** โ†’ Use Server only + +### 3) Configure pipeline +```csharp +// app.UseHttpsRedirection(); +// app.UseStaticFiles(); + +app.UseUltimateAuthWithAspNetCore(); // Includes UseAuthentication() and UseAuthorization() +// Place Antiforgery or something else needed +app.MapUltimateAuthEndpoints(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); +``` + +### 4) Add UAuth Script +Place this in `App.razor` or `index.html` in your Blazor client application: +```csharp + +``` + +### 5) ๐Ÿ—„๏ธ Database Setup (EF Core) + +After configuring UltimateAuth with Entity Framework Core, you need to create and apply database migrations. + +5.1) Install EF Core tools (if not installed) +```bash +dotnet tool install --global dotnet-ef +``` +5.2) Add migration +```bash +dotnet ef migrations add InitUAuth +``` + +5.3) Update database +```bash +dotnet ef database update +``` +๐Ÿ’ก Visual Studio (PMC alternative) + +If you are using Visual Studio, you can run these commands in Package Manager Console: +```bash +Add-Migration InitUAuth -Context UAuthDbContext +Update-Database -Context UAuthDbContext +``` +โš ๏ธ Notes +- Migrations must be created in your application project, not in the UltimateAuth packages +- You are responsible for managing migrations in production +- Automatic database initialization is not enabled by default + +### 6) Optional: Blazor Usings +Add this in `_Imports.razor` +```csharp +@using CodeBeam.UltimateAuth.Client.Blazor +``` + +### โœ… Done + +--- + +## ๐Ÿ’ก Usage + +Inject IUAuthClient and simply call methods. + +### Examples +Login +```csharp +[Inject] IUAuthClient UAuthClient { get; set; } = null!; + +private async Task Login() +{ + var request = new LoginRequest + { + Identifier = "UAuthUser", + Secret = "UAuthPassword", + }; + await UAuthClient.Flows.LoginAsync(request); +} +``` + +Register +```csharp +[Inject] IUAuthClient UAuthClient { get; set; } = null!; + +private async Task Register() +{ + var request = new CreateUserRequest + { + UserName = _username, + Password = _password, + Email = _email, + }; + + var result = await UAuthClient.Users.CreateAsync(request); + if (result.IsSuccess) + { + Console.WriteLine("User created successfully."); + } + else + { + Console.WriteLine(result.ErrorText ?? "Failed to create user."); + } +} +``` + +LogoutAll But Keep Current Device +```csharp +[Inject] IUAuthClient UAuthClient { get; set; } = null!; + +private async Task LogoutOthersAsync() +{ + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); + Console.WriteLine(result.IsSuccess); +} +``` + +UltimateAuth turns Auth into a simple application service โ€” not a separate system you fight against. +- No manual token handling +- No custom HTTP plumbing +- No fragile redirect logic +- All built-in with extensible options. --- @@ -87,23 +303,10 @@ Create accounts, simulate devices, test auth flows, and observe UltimateAuth in UltimateAuth is a community-first framework. We welcome proposals, discussions, architectural insights, and contributions of all sizes. -Before contributing, please see: - -- `ROADMAP.md` โ€” Release strategy - Discussions are open โ€” your ideas matter. --- -## ๐Ÿ›  Project Status - -UltimateAuth is currently in the design and specification phase. -We are shaping the architecture with real-world requirements, especially from Blazor and MAUI applications. - -Early API drafts and prototypes will be published soon. - ---- - ## โญ Acknowledgements UltimateAuth is built with love by CodeBeam and shaped by real-world .NET development โ€” diff --git a/UltimateAuth.slnx b/UltimateAuth.slnx index 21efc310..4d101bde 100644 --- a/UltimateAuth.slnx +++ b/UltimateAuth.slnx @@ -1,10 +1,58 @@ + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/.gitkeep b/docs/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/docs/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/docs/content/auth-flows/device-management.md b/docs/content/auth-flows/device-management.md new file mode 100644 index 00000000..9e9305bf --- /dev/null +++ b/docs/content/auth-flows/device-management.md @@ -0,0 +1,159 @@ +# ๐Ÿ“ฑ Device Management + +In UltimateAuth, devices are not an afterthought. + +๐Ÿ‘‰ They are a **first-class concept** + +## ๐Ÿง  Why Device Matters + +Most authentication systems ignore devices. + +- A user logs in +- A token is issued +- Everything is treated the same + +๐Ÿ‘‰ This breaks down when you need: + +- Multi-device control +- Session visibility +- Security enforcement + +๐Ÿ‘‰ UltimateAuth solves this with **device-aware authentication** + +## ๐Ÿงฉ Core Concept: Chain = Device + +In UltimateAuth: + +๐Ÿ‘‰ A **SessionChain represents a device** + +``` +Device โ†’ Chain โ†’ Sessions +``` + +Each chain: + +- Is bound to a device +- Groups sessions +- Tracks activity + +๐Ÿ‘‰ A device is not inferred โ€” it is explicitly modeled + +## ๐Ÿ”— What Defines a Device? + +A chain includes: + +- DeviceId +- Platform (web, mobile, etc.) +- Operating System +- Browser +- IP (optional binding) + +๐Ÿ‘‰ This forms a **device fingerprint** + +## ๐Ÿ”„ Device Lifecycle + +### 1๏ธโƒฃ First Login + +- New device detected +- New chain is created + +### 2๏ธโƒฃ Subsequent Logins + +- Same device โ†’ reuse chain +- New device โ†’ new chain + +๐Ÿ‘‰ Device continuity is preserved + +### 3๏ธโƒฃ Activity (Touch) + +- Chain `LastSeenAt` updated +- `TouchCount` increases + +๐Ÿ‘‰ Tracks real usage + +### 4๏ธโƒฃ Token Rotation + +- Session changes +- Chain remains +- `RotationCount` increases + +๐Ÿ‘‰ Device identity stays stable + +### 5๏ธโƒฃ Logout + +- Session removed +- Chain remains + +๐Ÿ‘‰ Device still trusted + +### 6๏ธโƒฃ Revoke + +- Chain invalidated +- All sessions removed + +๐Ÿ‘‰ Device trust is reset + +
+ +## ๐Ÿ” Security Model + +### ๐Ÿ”— Device Binding + +Sessions and tokens are tied to: + +- Chain +- Device context + +๐Ÿ‘‰ Prevents cross-device reuse + +### ๐Ÿ” Rotation Tracking + +Chains track: + +- RotationCount +- TouchCount + +๐Ÿ‘‰ Enables anomaly detection + +### ๐Ÿšจ Revoke Cascade + +If a device is compromised: + +- Entire chain can be revoked +- All sessions invalidated + +๐Ÿ‘‰ Immediate containment + +
+ +## โš™๏ธ Configuration + +Device behavior is configurable via session options: + +- Max chains per user +- Max sessions per chain +- Platform-based limits +- Device mismatch behavior + +๐Ÿ‘‰ Fine-grained control for enterprise scenarios + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Device = Chain +๐Ÿ‘‰ Not just metadata + +## ๐Ÿ“Œ Key Takeaways + +- Devices are explicitly modeled +- Each device has its own chain +- Sessions belong to chains +- Security is enforced per device +- Logout and revoke operate on device scope + +## โžก๏ธ Next Step + +Continue to **Configuration & Extensibility** diff --git a/docs/content/auth-flows/index.md b/docs/content/auth-flows/index.md new file mode 100644 index 00000000..94d06100 --- /dev/null +++ b/docs/content/auth-flows/index.md @@ -0,0 +1,136 @@ +# ๐Ÿ” Auth Flows + +Authentication in UltimateAuth is not a single operation. + +๐Ÿ‘‰ It is a **flow-driven system**. + +
+ +## ๐Ÿง  What is an Auth Flow? +An auth flow represents a complete authentication operation, such as: + +- Logging in +- Refreshing a session +- Logging out + +Each flow: + +- Has a defined lifecycle +- Runs through the orchestration pipeline +- Produces a controlled authentication outcome + +๐Ÿ‘‰ Instead of calling isolated APIs, you execute **flows**. + +## ๐Ÿ”„ Why Flow-Based? +Traditional systems treat authentication as: + +- A login endpoint +- A token generator +- A cookie setter + +๐Ÿ‘‰ These approaches often lead to fragmented logic. + +UltimateAuth solves this by: +- Structuring authentication as flows +- Enforcing a consistent execution model +- Centralizing security decisions + +
+ +## ๐Ÿงฉ What Happens During a Flow? +Every flow follows the same pattern: +``` +Flow โ†’ Context โ†’ Orchestrator โ†’ Authority โ†’ Result +``` + +- The **flow** defines the intent +- The **context** carries state +- The **orchestrator** coordinates execution +- The **authority** enforces rules + +๐Ÿ‘‰ This ensures consistent and secure behavior across all operations. + +
+ +## ๐Ÿ” Types of Flows +UltimateAuth provides built-in flows for common scenarios: + +### ๐Ÿ”‘ Login Flow +Establishes authentication by: + +- Validating credentials +- Creating session hierarchy (root, chain, session) +- Issuing tokens if required + +๐Ÿ‘‰ [Learn more](./login-flow.md) + +### ๐Ÿ”„ Refresh Flow +Extends an existing session: + +- Rotates refresh tokens +- Maintains session continuity +- Applies sliding expiration + +๐Ÿ‘‰ [Learn more](./refresh-flow.md) + +### ๐Ÿšช Logout Flow +Terminates authentication: + +- Revokes session(s) +- Invalidates tokens +- Supports device-level or global logout + +๐Ÿ‘‰ [Learn more](./logout-flow.md) + +
+ +## ๐Ÿง  Supporting Concepts +These flows operate on top of deeper system models: + +### ๐Ÿงฌ Session Lifecycle + +- Root โ†’ Chain โ†’ Session hierarchy +- Device-aware session structure +- Lifecycle management and revocation + +๐Ÿ‘‰ [Learn more](./session-lifecycle.md) + +### ๐ŸŽŸ Token Behavior + +- Access token vs refresh token +- Opaque vs JWT +- Mode-dependent behavior + +๐Ÿ‘‰ [Learn more](./token-behavior.md) + +### ๐Ÿ“ฑ Device Management + +- Device binding +- Multi-device sessions +- Security implications + +๐Ÿ‘‰ [Learn more](./device-management.md) + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ **Authentication is not a single step** +๐Ÿ‘‰ **It is a controlled flow of state transitions** + +## ๐Ÿ“Œ Key Takeaways + +- Authentication is executed as flows +- Each flow follows a consistent pipeline +- Sessions and tokens are created as part of flows +- Security is enforced centrally + +--- + +## โžก๏ธ Next Step + +Start with the most important flow: + +๐Ÿ‘‰ Continue to **Login Flow** diff --git a/docs/content/auth-flows/login-flow.md b/docs/content/auth-flows/login-flow.md new file mode 100644 index 00000000..c5913c3f --- /dev/null +++ b/docs/content/auth-flows/login-flow.md @@ -0,0 +1,156 @@ +# ๐Ÿ”‘ Login Flow +The login flow in UltimateAuth is not just credential validation. + +๐Ÿ‘‰ It is a **controlled session establishment process**. + +
+ +## ๐Ÿง  What is Login? +In traditional systems: + +- Validate credentials +- Issue a token + +In UltimateAuth: + +๐Ÿ‘‰ Login creates a **session hierarchy** +``` +Root โ†’ Chain โ†’ Session +``` + +> Login does not create a token +> โ†’ It creates a session + +Tokens are optional outputs derived from the session. + +
+ +## ๐Ÿ”„ Step-by-Step Execution +The login flow follows a structured pipeline: + +### 1๏ธโƒฃ Identifier Resolution +The system resolves the user identity: + +- Username / email / phone โ†’ `UserKey` + +### 2๏ธโƒฃ User & Security State +The system loads: + +- User existence +- Account state +- Factor (credential) state + +Checks include: + +- Is the account locked? +- Is reauthentication required? + +### 3๏ธโƒฃ Credential Validation +Credentials are validated using providers: + +- Password +- (Extensible: OTP, external providers, etc.) + +### 4๏ธโƒฃ Authority Decision +The **LoginAuthority** evaluates the attempt. + +Possible outcomes: + +- โœ… Allow +- โŒ Deny +- โš ๏ธ Challenge (e.g. MFA) + +๐Ÿ‘‰ No session is created before this decision. + +### 5๏ธโƒฃ Device & Chain Resolution +The system checks if the device is known: + +- Existing device โ†’ reuse chain +- New device โ†’ create new chain + +๐Ÿ‘‰ A **Chain represents a device** + +### 6๏ธโƒฃ Session Creation +A new session is issued: + +- Linked to user +- Linked to device (chain) +- Bound to tenant + +Session hierarchy: +``` +User โ†’ Root โ†’ Chain โ†’ Session +``` + +### 7๏ธโƒฃ Token Issuance (Optional) +Depending on the mode and request: + +- Access token may be issued +- Refresh token may be issued + +๐Ÿ‘‰ Tokens are derived from the session +๐Ÿ‘‰ Not the source of truth + +### 8๏ธโƒฃ Event Dispatch +The system emits: + +- Login events +- Audit information + +
+ +## ๐Ÿงฉ What Gets Created? +A successful login creates: + +### ๐Ÿ”น Root +- One per user +- Represents global authentication state + +### ๐Ÿ”น Chain +- One per device +- Manages device lifecycle + +### ๐Ÿ”น Session +- Individual authentication instance +- Represents a single login + +
+ +## ๐Ÿ“ฑ Device Awareness +UltimateAuth is device-aware by design: + +- Each device gets its own chain +- Sessions are grouped by device +- Logout can target device or all sessions + +
+ +## ๐Ÿ” Security Considerations +The login flow includes built-in protections: + +- Account lockout +- Failed attempt tracking +- Device binding +- Security version validation + +๐Ÿ‘‰ Security decisions are centralized in the Authority + +
+ +## ๐Ÿง  Mental Model +If you remember one thing: + +๐Ÿ‘‰ Login = session creation +๐Ÿ‘‰ Not token issuance + +## ๐Ÿ“Œ Key Takeaways + +- Login is a flow, not a function +- Authority decides before any state change +- Sessions are the source of truth +- Tokens are optional representations +- Device context is always considered + +## โžก๏ธ Next Step + +Continue to **Refresh Flow** diff --git a/docs/content/auth-flows/logout-flow.md b/docs/content/auth-flows/logout-flow.md new file mode 100644 index 00000000..f6b933f0 --- /dev/null +++ b/docs/content/auth-flows/logout-flow.md @@ -0,0 +1,174 @@ +# ๐Ÿšช Logout Flow +The logout flow in UltimateAuth is not a single action. + +๐Ÿ‘‰ It represents different **levels of authentication termination**. + +## ๐Ÿง  What is Logout? +In traditional systems: + +- Logout = remove cookie or token + +In UltimateAuth: + +๐Ÿ‘‰ Logout affects **session, device, or identity scope** + +> Logout is not just removing access +> โ†’ It is controlling session lifecycle + +## ๐Ÿ”€ Logout vs Revoke +UltimateAuth distinguishes between two concepts: + +### ๐Ÿ”น Logout (Soft Termination) + +- Ends the current session +- Keeps the device (chain) active +- Allows re-login without resetting device context + +``` +Session โ†’ Invalidated +Chain โ†’ Still Active +``` + +๐Ÿ‘‰ User can log in again and continue on the same device chain + +### ๐Ÿ”ฅ Revoke (Hard Invalidation) +- Invalidates session, chain, or root +- Cannot be undone +- Forces a completely new authentication path + +``` +Chain โ†’ Revoked +Sessions โ†’ Revoked +Next login โ†’ New chain +``` + +๐Ÿ‘‰ Revoke resets trust for that scope + +
+ +## ๐Ÿงฉ Levels of Termination +UltimateAuth supports multiple logout scopes: + +### ๐Ÿ”น Session-Level Logout +- Terminates a single session +- Other sessions on the same device may remain + +### ๐Ÿ“ฑ Device-Level (Chain) +- Terminates all sessions on a device +- Device chain is invalidated or reset + +### ๐ŸŒ Global Logout (All Devices) +- Terminates all sessions across all devices +- Keeps root (user identity) intact + +### ๐Ÿ”ฅ Root Revoke +- Invalidates entire authentication state +- All chains and sessions are revoked + +๐Ÿ‘‰ This is the strongest possible action + +
+ +## ๐Ÿ”„ Step-by-Step Execution + +### 1๏ธโƒฃ Flow Context Resolution +The system resolves: + +- Current session +- User identity +- Tenant + +### 2๏ธโƒฃ Authority Decision +Logout operations are validated: + +- Authorization checks +- Access validation + +๐Ÿ‘‰ Logout is not blindly executed + +### 3๏ธโƒฃ Scope Determination +The system determines what to terminate: + +- Session +- Chain +- Root + +### 4๏ธโƒฃ Execution +Depending on scope: + +#### Session Logout +- Session is revoked +- Other sessions unaffected + +#### Chain Revoke / Logout +- All sessions in the chain are revoked +- Device trust is reset + +#### Global Logout +- All chains are revoked (optionally excluding current) + +#### Root Revoke +- Entire identity state is invalidated + +### 5๏ธโƒฃ Event Dispatch +The system emits: + +- Logout events +- Audit logs + +
+ +## ๐Ÿ“ฑ Device Awareness +Logout behavior is device-aware: + +- Each device is a chain +- Logout can target specific devices +- Sessions are grouped by device + +๐Ÿ‘‰ This enables fine-grained control + +
+ +## ๐Ÿ” Security Model + +### ๐Ÿ”’ Controlled Termination +All logout operations: + +- Pass through orchestrator +- Are validated by authority + +๐Ÿ‘‰ Prevents unauthorized session manipulation + +### ๐Ÿ” Irreversible Revocation +- Revoked chains cannot be restored +- Revoked sessions remain invalid + +๐Ÿ‘‰ Ensures strong security guarantees + +### ๐Ÿ”— Identity Boundaries + +- Session โ†’ temporary identity proof +- Chain โ†’ device trust boundary +- Root โ†’ global identity state + +๐Ÿ‘‰ Logout operates within these boundaries + +
+ +## ๐Ÿง  Mental Model +If you remember one thing: + +๐Ÿ‘‰ Logout = ending a session +๐Ÿ‘‰ Revoke = resetting trust + +## ๐Ÿ“Œ Key Takeaways + +- Logout and revoke are different operations +- Logout is reversible (via re-login) +- Revoke is permanent and forces new authentication +- Device (chain) is a first-class concept +- Security is enforced through authority and orchestrator + +## โžก๏ธ Next Step + +Continue to **Session Lifecycle** diff --git a/docs/content/auth-flows/refresh-flow.md b/docs/content/auth-flows/refresh-flow.md new file mode 100644 index 00000000..52ca0a68 --- /dev/null +++ b/docs/content/auth-flows/refresh-flow.md @@ -0,0 +1,174 @@ +# ๐Ÿ”„ Refresh Flow +The refresh flow in UltimateAuth is not a single fixed operation. + +๐Ÿ‘‰ It is a **mode-dependent continuation strategy**. + +## ๐Ÿง  What is Refresh? +In traditional systems: + +- Refresh = get a new access token + +In UltimateAuth: + +๐Ÿ‘‰ Refresh continues an existing authentication state + +> Refresh is not re-authentication +> โ†’ It is session continuation + +## ๐Ÿ”€ Two Refresh Strategies +UltimateAuth supports two fundamentally different refresh behaviors: + +### ๐Ÿ”น Session Touch (Stateful) +Used in **PureOpaque mode** + +- No tokens involved +- No new session created +- Existing session is extended + +``` +Session โ†’ Validate โ†’ Touch โ†’ Continue +``` + +๐Ÿ‘‰ This updates activity without changing identity + +### ๐Ÿ”น Token Rotation (Stateless / Hybrid) +Used in: + +- Hybrid +- SemiHybrid +- PureJwt + +- Refresh token is validated +- Old token is revoked +- New tokens are issued + +``` +RefreshToken โ†’ Validate โ†’ Revoke โ†’ Issue New Tokens +``` + +๐Ÿ‘‰ This ensures forward security + +
+ +## โš–๏ธ Mode-Based Behavior +Refresh behavior is determined by the authentication mode: + +| Mode | Behavior | +|-------------|-----------------------| +| PureOpaque | Session Touch | +| Hybrid | Rotation + Touch | +| SemiHybrid | Rotation | +| PureJwt | Rotation | + +๐Ÿ‘‰ UltimateAuth automatically selects the correct strategy. + +
+ +## ๐Ÿ”„ Step-by-Step Execution + +### 1๏ธโƒฃ Input Resolution +The system resolves: + +- SessionId (if present) +- RefreshToken (if present) +- Device context + +### 2๏ธโƒฃ Mode-Based Branching +The system determines the refresh strategy: + +- Session-based โ†’ Touch +- Token-based โ†’ Rotation +- Hybrid โ†’ Both + +### 3๏ธโƒฃ Session Validation +If session is involved: + +- Session is validated +- Device binding is checked +- Expiration is evaluated + +### 4๏ธโƒฃ Token Validation (if applicable) +If refresh token is used: + +- Token is validated +- Session and chain are verified +- Device consistency is checked + +### 5๏ธโƒฃ Security Checks +The system enforces: + +- Token reuse detection +- Session validity +- Chain integrity + +๐Ÿ‘‰ If validation fails โ†’ reauthentication required + +### 6๏ธโƒฃ Execution +Depending on the strategy: + +#### Session Touch +- Updates `LastSeenAt` +- Applies sliding expiration +- No new tokens issued + +#### Token Rotation +- Revokes old refresh token +- Issues new access token +- Issues new refresh token + +#### Hybrid Mode +- Validates session +- Rotates tokens +- Updates session activity + +### 7๏ธโƒฃ Response Generation +The response may include: + +- SessionId +- Access token +- Refresh token + +๐Ÿ‘‰ Output depends on mode and client + +
+ +## ๐Ÿ” Security Model +The refresh flow includes strong protections: + +### ๐Ÿ” Token Reuse Detection +If a refresh token is reused: + +- Chain may be revoked +- Session may be revoked + +๐Ÿ‘‰ This prevents replay attacks + +### ๐Ÿ”— Session Binding +- Tokens are bound to session +- Session is bound to device + +๐Ÿ‘‰ Prevents token misuse across devices + +### ๐Ÿงฌ Chain Integrity +- Refresh operates within a chain +- Cross-device usage is rejected + +
+ +## ๐Ÿง  Mental Model +If you remember one thing: + +๐Ÿ‘‰ Refresh = continuation +๐Ÿ‘‰ Not new authentication + +## ๐Ÿ“Œ Key Takeaways + +- Refresh behavior depends on auth mode +- Stateful systems use session touch +- Stateless systems use token rotation +- Hybrid systems combine both +- Security is enforced at every step + +## โžก๏ธ Next Step + +Continue to **Logout Flow** diff --git a/docs/content/auth-flows/session-lifecycle.md b/docs/content/auth-flows/session-lifecycle.md new file mode 100644 index 00000000..f628bab8 --- /dev/null +++ b/docs/content/auth-flows/session-lifecycle.md @@ -0,0 +1,194 @@ +# ๐Ÿงฌ Session Lifecycle +UltimateAuth is built around a structured session model. + +๐Ÿ‘‰ Authentication is not a token +๐Ÿ‘‰ It is a **hierarchical session system** + +## ๐Ÿง  Core Model + +UltimateAuth defines three core entities: +``` +Root โ†’ Chain โ†’ Session +``` + +### ๐Ÿ”น Root (Identity Authority) +- One per user +- Represents global authentication state +- Holds security version + +๐Ÿ‘‰ Root defines **who the user is** + +### ๐Ÿ“ฑ Chain (Device Context) +- One per device +- Represents a device-bound identity context +- Tracks activity (LastSeenAt) +- Manages session rotation and touch + +๐Ÿ‘‰ A chain is a **trusted device boundary** + +### ๐Ÿ”‘ Session (Authentication Instance) +- Created on login +- Represents a single authentication event +- Has expiration and revocation state + +๐Ÿ‘‰ A session is a **proof of authentication** + +
+ +## ๐Ÿ”— Relationship +``` +User +โ””โ”€โ”€ Root +โ””โ”€โ”€ Chain (Device) +โ””โ”€โ”€ Session (Instance) +``` + +๐Ÿ‘‰ Each level adds more specificity: + +- Root โ†’ identity +- Chain โ†’ device +- Session โ†’ login instance + +
+ +## ๐Ÿ”„ Lifecycle Overview + +### 1๏ธโƒฃ Creation (Login) +When a user logs in: + +- Root is created (if not exists) +- Chain is resolved or created +- Session is issued + +### 2๏ธโƒฃ Active Usage +During normal operation: + +- Session is validated +- Chain `LastSeenAt` is updated (touch) +- Sliding expiration may apply + +๐Ÿ‘‰ Activity updates the **chain**, not just the session + +### 3๏ธโƒฃ Refresh +Depending on mode: + +#### Session-Based (PureOpaque) +- Session remains +- Chain is touched + +#### Token-Based (Hybrid / JWT) +- Session continues +- Tokens are rotated +- Chain rotation count increases + +๐Ÿ‘‰ Chain tracks behavior: + +- RotationCount +- TouchCount + +### 4๏ธโƒฃ Expiration +A session may expire due to: + +- Lifetime expiration +- Idle timeout +- Absolute expiration + +๐Ÿ‘‰ Expired โ‰  revoked + +### 5๏ธโƒฃ Revocation +Revocation can occur at multiple levels: + +#### Session Revocation +- Single session invalidated + +#### Chain Revocation +- All sessions on device invalidated +- Device trust reset + +--- + +#### Root Revocation + +- All chains and sessions invalidated +- Security version increased + +๐Ÿ‘‰ Revocation is irreversible + +
+ +## ๐Ÿ” Security Model + +### ๐Ÿ”’ Security Versioning +Each root has: +- `SecurityVersion` + +Each session stores: +- `SecurityVersionAtCreation` + +--- + +๐Ÿ‘‰ If mismatch: + +```text +Session becomes invalid +``` + +### ๐Ÿ”— Device Binding +Each chain is tied to: + +- DeviceId +- Platform +- OS +- Browser + +๐Ÿ‘‰ Prevents cross-device misuse + +### ๐Ÿ” Rotation Tracking +Chains track: + +- RotationCount +- TouchCount + +๐Ÿ‘‰ Enables: + +- replay detection +- anomaly tracking + +### โš™๏ธ Lifecycle Configuration +Session behavior is configurable: + +โฑ Lifetime +- Default session duration + +๐Ÿ”„ Sliding Expiration +- Extends session on activity + +๐Ÿ’ค Idle Timeout +- Invalidates inactive sessions + +๐Ÿ“ฑ Device Limits +- Max chains per user +- Max sessions per chain + +๐Ÿ‘‰ These are defined via UAuthSessionOptions + +
+ +## ๐Ÿง  Mental Model +If you remember one thing: + +๐Ÿ‘‰ Authentication is a living structure +๐Ÿ‘‰ Not a static token + +## ๐Ÿ“Œ Key Takeaways +- Sessions are part of a hierarchy +- Device (chain) is a first-class concept +- Root controls global security +- Sessions are short-lived proofs +- Chains manage lifecycle and activity +- Revocation operates at multiple levels + +## โžก๏ธ Next Step + +Continue to Token Behavior + diff --git a/docs/content/auth-flows/token-behavior.md b/docs/content/auth-flows/token-behavior.md new file mode 100644 index 00000000..f98d056e --- /dev/null +++ b/docs/content/auth-flows/token-behavior.md @@ -0,0 +1,162 @@ +# ๐ŸŽŸ Token Behavior +In UltimateAuth, tokens are not the foundation of authentication. + +๐Ÿ‘‰ They are **derived artifacts of a session** + +## ๐Ÿง  Rethinking Tokens +In traditional systems: + +- Token = identity +- Token = authentication + +In UltimateAuth: + +๐Ÿ‘‰ Session = identity +๐Ÿ‘‰ Token = transport mechanism + +> Tokens do not define identity +> โ†’ Sessions do + +## ๐Ÿงฉ Token Types +UltimateAuth supports two main token types: + +### ๐Ÿ”น Opaque Tokens +- Random, non-decodable values +- Stored and validated on the server +- Typically reference a session + +๐Ÿ‘‰ Used in: + +- PureOpaque +- Hybrid + +### ๐Ÿ”น JWT (JSON Web Tokens) +- Self-contained tokens +- Include claims and metadata +- Signed and verifiable without server lookup + +๐Ÿ‘‰ Used in: + +- SemiHybrid +- PureJwt + +
+ +## โš–๏ธ Mode-Based Behavior +Token behavior depends on the authentication mode: + +| Mode | Access Token | Refresh Token | Behavior | +|-------------|---------------|----------------|---------------------------| +| PureOpaque | โŒ | โŒ | Session-only | +| Hybrid | โœ” (opaque/JWT)| โœ” | Session + token | +| SemiHybrid | โœ” (JWT) | โœ” | JWT + session metadata | +| PureJwt | โœ” (JWT) | โœ” | Fully stateless | + +๐Ÿ‘‰ UltimateAuth selects behavior automatically + +
+ +## ๐Ÿ”‘ Access Tokens +Access tokens represent: + +๐Ÿ‘‰ A **temporary access grant** + +### Characteristics +- Short-lived +- Mode-dependent format +- May contain session reference (`sid`) +- May include claims + +### Important +Access token is NOT the source of truth. + +๐Ÿ‘‰ It reflects session state, not replaces it + +## ๐Ÿ”„ Refresh Tokens +Refresh tokens represent: + +๐Ÿ‘‰ A **continuation mechanism** + +### Characteristics +- Long-lived +- Stored as hashed values +- Bound to session and optionally chain +- Rotated on use + +### Lifecycle +Issued โ†’ Used โ†’ Replaced โ†’ Revoked + +๐Ÿ‘‰ Old tokens are invalidated on rotation + +
+ +## ๐Ÿ” Security Model + +### ๐Ÿ” Rotation +Each refresh: + +- Invalidates previous token +- Issues a new token + +๐Ÿ‘‰ Prevents replay attacks + +### ๐Ÿšจ Reuse Detection +If a token is reused: + +- Chain may be revoked +- Session may be revoked + +๐Ÿ‘‰ Strong forward security + +### ๐Ÿ”— Session Binding +Refresh tokens include: + +- SessionId +- ChainId (optional) + +๐Ÿ‘‰ Prevents cross-context usage + +### ๐Ÿ”’ Hashed Storage +Tokens are: + +- Never stored as plaintext +- Hashed using secure algorithms + +๐Ÿ‘‰ Reduces breach impact + +
+ +## ๐Ÿ”„ Token Issuance +Tokens are issued during: + +- Login +- Refresh + +### Access Token +- May be opaque or JWT +- Includes identity and optional session reference + +### Refresh Token +- Always opaque +- Persisted in secure store +- Used only for rotation + +
+ +## ๐Ÿง  Mental Model +If you remember one thing: + +๐Ÿ‘‰ Tokens are not identity +๐Ÿ‘‰ They are projections of a session + +## ๐Ÿ“Œ Key Takeaways +- Session is the source of truth +- Tokens are optional and mode-dependent +- Opaque tokens require server validation +- JWT tokens allow stateless access +- Refresh tokens enable secure continuation +- Token rotation ensures forward security + +## โžก๏ธ Next Step + +Continue to Device Management diff --git a/docs/content/client/authentication.md b/docs/content/client/authentication.md new file mode 100644 index 00000000..4148c02d --- /dev/null +++ b/docs/content/client/authentication.md @@ -0,0 +1,180 @@ +# ๐Ÿ” Authentication Guide + +This section explains how to use the UltimateAuth client for authentication flows. + +## ๐Ÿง  Overview + +Authentication in UltimateAuth is **flow-based**, not endpoint-based. + +You interact with: + +๐Ÿ‘‰ `FlowClient` + +--- + +## ๐Ÿ”‘ Login + +### Basic Login + +```csharp +await UAuthClient.Flows.LoginAsync(new LoginRequest +{ + Identifier = "user@ultimateauth.com", + Secret = "password" +}); +``` + +๐Ÿ‘‰ This triggers a full login flow: + +- Sends credentials +- Handles redirect +- Establishes session + +--- + +## โšก Try Login (Pre-check) + +```csharp +var result = await UAuthClient.Flows.TryLoginAsync( + new LoginRequest + { + Identifier = "user@mail.com", + Secret = "password" + }, + UAuthSubmitMode.TryOnly +); +``` + +### Modes + +| Mode | Behavior | +|--------------|--------------------------------| +| TryOnly | Validate only | +| DirectCommit | Skip validation | +| TryAndCommit | Validate then login if success | + +๐Ÿ‘‰ Use `DirectCommit` when: +- You need maximum performance while sacrificing interactive SPA capabilities. + +๐Ÿ‘‰ Use `TryOnly` when: + +- You need validation feedback +- You want custom UI flows + +๐Ÿ‘‰ Use `TryAndCommit` when: + +- You need completely interactive SPA experience. + +๐Ÿ‘‰ `TryAndCommit` is the recommended mode for most applications. + +It provides: + +- Validation feedback +- Automatic redirect on success +- Smooth SPA experience + +
+ +## ๐Ÿ”„ Refresh + +```csharp +var result = await UAuthClient.Flows.RefreshAsync(); +``` + +### Possible Outcomes + +- Success โ†’ new tokens/session updated +- Touched โ†’ session extended +- Rotated โ†’ refresh token rotated +- NoOp โ†’ nothing changed +- ReauthRequired โ†’ login required + +๐Ÿ‘‰ Refresh behavior depends on auth mode: + +- PureOpaque โ†’ session touch +- Hybrid/JWT โ†’ token rotation + +In default, successful refresh returns success outcome. If you want to learn success detail such as no-op, touched or rotated, open it via server options: + +```csharp +builder.Services.AddUltimateAuthServer(o => +{ + o.Diagnostics.EnableRefreshDetails = true; +}); +``` + +
+ +## ๐Ÿšช Logout + +```csharp +await UAuthClient.Flows.LogoutAsync(); +``` + +๐Ÿ‘‰ This: + +- Ends current session +- Clears authentication state + +
+ +## ๐Ÿ“ฑ Device Logout Variants + +```csharp +await UAuthClient.Flows.LogoutMyDeviceAsync(sessionId); +await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); +await UAuthClient.Flows.LogoutAllMyDevicesAsync(); +``` + +๐Ÿ‘‰ These operate on **session chains (devices)** + +
+ +## ๐Ÿ” PKCE Flow (Public Clients) + +### Start PKCE + +```csharp +await UAuthClient.Flows.BeginPkceAsync(); +``` + +### Complete PKCE + +```csharp +await UAuthClient.Flows.CompletePkceLoginAsync(request); +``` + +> Complete PKCE also has try semantics the same as login flow. UltimateAuth suggests to use `TryCompletePkceLoginAsync` for complete interactive experience. + +๐Ÿ‘‰ Required for: + +- SPA +- Blazor WASM +- Mobile apps + +--- + +## ๐Ÿšจ Security Note + +- Public clients MUST use PKCE +- Server clients MAY allow direct login + +Direct credential posting disabled by default and throws exception when you directly call login. You can enable it via options. You should only use it for debugging and development purposes. +```csharp +builder.Services.AddUltimateAuthClientBlazor(o => +{ + o.Login.AllowCredentialPost = true; +}); +--- + +## ๐ŸŽฏ Summary + +Authentication in UltimateAuth: + +- is flow-driven +- adapts to client type +- enforces security by design + +--- + +๐Ÿ‘‰ Always use `FlowClient` for authentication operations diff --git a/docs/content/client/authorization.md b/docs/content/client/authorization.md new file mode 100644 index 00000000..8880bc18 --- /dev/null +++ b/docs/content/client/authorization.md @@ -0,0 +1,129 @@ +# ๐Ÿ›ก Authorization Guide + +This section explains how to manage roles, permissions, and access control using the UltimateAuth client. + +## ๐Ÿง  Overview + +Authorization in UltimateAuth is policy-driven and role-based. + +On the client, you interact with: +``` +UAuthClient.Authorization +``` + +
+ +## ๐Ÿ”‘ Core Concepts +Roles +- Named groups of permissions +- Assigned to users + +Permissions +- Fine-grained access definitions +- Example: users.create.anonymous, users.delete.self, authorization.roles.admin + +Policies +- Runtime decision rules +- Enforced automatically on server + +
+ +### ๐Ÿ“‹ Query Roles +var result = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery +{ + PageNumber = 1, + PageSize = 10 +}); + +### โž• Create Role +await UAuthClient.Authorization.CreateRoleAsync(new CreateRoleRequest +{ + Name = "Manager" +}); + +### โœ๏ธ Rename Role +await UAuthClient.Authorization.RenameRoleAsync(new RenameRoleRequest +{ + Id = roleId, + Name = "NewName" +}); + +### ๐Ÿงฉ Set Permissions +await UAuthClient.Authorization.SetRolePermissionsAsync(new SetRolePermissionsRequest +{ + RoleId = roleId, + Permissions = new[] + { + Permission.From("users.read"), + Permission.From("users.update") + } +}); + +๐Ÿ‘‰ Permissions support: + +- Exact match โ†’ users.create +- Prefix โ†’ users.* +- Wildcard โ†’ * + +### โŒ Delete Role +await UAuthClient.Authorization.DeleteRoleAsync(new DeleteRoleRequest +{ + Id = roleId +}); + +๐Ÿ‘‰ Automatically removes role assignments from users + +### ๐Ÿ‘ค Assign Role to User +await UAuthClient.Authorization.AssignRoleToUserAsync(new AssignRoleRequest +{ + UserKey = userKey, + RoleName = "Manager" +}); + +### โž– Remove Role +await UAuthClient.Authorization.RemoveRoleFromUserAsync(new RemoveRoleRequest +{ + UserKey = userKey, + RoleName = "Manager" +}); + +### ๐Ÿ“‹ Get User Roles +var roles = await UAuthClient.Authorization.GetUserRolesAsync(userKey); +๐Ÿ” Check Authorization +var result = await UAuthClient.Authorization.CheckAsync(new AuthorizationCheckRequest +{ + Action = "users.delete" +}); + +๐Ÿ‘‰ Returns: + +- Allow / Deny +- Reason (if denied) + +
+ +## ๐Ÿง  Permission Model + +Permissions are normalized and optimized: + +- Full access โ†’ * +- Group access โ†’ users.* +- Specific โ†’ users.create + +๐Ÿ‘‰ Internally compiled for fast evaluation + +
+ +## ๐Ÿ” Security Notes +- Authorization is enforced server-side +- Client only requests actions +- Policies may override permissions +- Cross-tenant access is denied by default + +## ๐ŸŽฏ Summary + +Authorization in UltimateAuth: + +- combines roles + permissions + policies +- is evaluated through a decision pipeline +- supports fine-grained and scalable access control diff --git a/docs/content/client/credentials.md b/docs/content/client/credentials.md new file mode 100644 index 00000000..0df5b06c --- /dev/null +++ b/docs/content/client/credentials.md @@ -0,0 +1,167 @@ +# ๐Ÿ”‘ Credential Management Guide + +This section explains how to manage user credentials (such as passwords) using the UltimateAuth client. + +## ๐Ÿง  Overview + +Credential operations are handled via: + +```csharp +UAuthClient.Credentials... +``` + +This includes: + +- password management +- credential reset flows +- admin credential operations + +
+ +## ๐Ÿ” Change Password (Self) + +```csharp +await UAuthClient.Credentials.ChangeMyAsync(new ChangeCredentialRequest +{ + CurrentSecret = "old-password", + NewSecret = "new-password" +}); +``` + +๐Ÿ‘‰ Requires current password +๐Ÿ‘‰ Triggers `CredentialsChangedSelf` event + +## ๐Ÿ” Reset Password (Self) + +### Begin Reset + +```csharp +var begin = await UAuthClient.Credentials.BeginResetMyAsync( + new BeginResetCredentialRequest + { + Identifier = "user@mail.com" + }); +``` + +### Complete Reset + +```csharp +await UAuthClient.Credentials.CompleteResetMyAsync( + new CompleteResetCredentialRequest + { + Token = "reset-token", + NewSecret = "new-password" + }); +``` + +๐Ÿ‘‰ Typically used in: + +- forgot password flows +- email-based reset flows + +## โž• Add Credential (Self) + +```csharp +await UAuthClient.Credentials.AddMyAsync(new AddCredentialRequest +{ + Secret = "password" +}); +``` + +## โŒ Revoke Credential (Self) + +```csharp +await UAuthClient.Credentials.RevokeMyAsync(new RevokeCredentialRequest +{ + CredentialId = credentialId +}); +``` + +๐Ÿ‘‰ Useful for: + +- removing login methods +- invalidating compromised credentials + +
+ +## ๐Ÿ‘‘ Admin: Change User Credential + +```csharp +await UAuthClient.Credentials.ChangeUserAsync(userKey, new ChangeCredentialRequest +{ + NewSecret = "new-password" +}); +``` + +๐Ÿ‘‰ Does NOT require current password + +## ๐Ÿ‘‘ Admin: Add Credential + +```csharp +await UAuthClient.Credentials.AddUserAsync(userKey, new AddCredentialRequest +{ + Secret = "password" +}); +``` + +## ๐Ÿ‘‘ Admin: Revoke Credential + +```csharp +await UAuthClient.Credentials.RevokeUserAsync(userKey, new RevokeCredentialRequest +{ + CredentialId = credentialId +}); +``` + +## ๐Ÿ‘‘ Admin: Reset Credential + +### Begin + +```csharp +await UAuthClient.Credentials.BeginResetUserAsync(userKey, request); +``` + +### Complete + +```csharp +await UAuthClient.Credentials.CompleteResetUserAsync(userKey, request); +``` + +## โŒ Delete Credential (Admin) + +```csharp +await UAuthClient.Credentials.DeleteUserAsync(userKey, new DeleteCredentialRequest +{ + CredentialId = credentialId +}); +``` + +## ๐Ÿง  Credential Model + +Credentials are: + +- user-bound +- security-version aware +- lifecycle-managed + +๐Ÿ‘‰ Changing credentials may: + +- invalidate sessions +- trigger security updates + +
+ +## ๐Ÿ” Security Notes + +- Passwords are never sent back to client +- All secrets are hashed server-side +- Reset flows should be protected (email, OTP, etc.) +- Admin operations are policy-protected + +## ๐ŸŽฏ Summary + +Credential management in UltimateAuth: + +- supports self-service and admin flows +- integrates with security lifecycle +- enables safe credential rotation diff --git a/docs/content/client/identifiers.md b/docs/content/client/identifiers.md new file mode 100644 index 00000000..0629a49e --- /dev/null +++ b/docs/content/client/identifiers.md @@ -0,0 +1,178 @@ +# ๐Ÿ†” User Identifiers Guide + +This section explains how UltimateAuth manages user identifiers such as email, username, and phone. + +## ๐Ÿง  Overview + +In UltimateAuth, identifiers are **first-class entities**. + +They are NOT just fields on the user. + +๐Ÿ‘‰ On the client, you interact with: + +```csharp +UAuthClient.Identifiers... +``` + +
+ +## ๐Ÿ”‘ What is an Identifier? + +An identifier represents a way to identify a user: + +- Email +- Username +- Phone number +- Custom identifiers + +๐Ÿ‘‰ Each identifier has: + +- Value (e.g. user@ultimateauth.com) +- Type (email, username, etc.) +- Verification state +- Primary flag + +
+ +## โญ Primary Identifier + +A user can have multiple identifiers, but only one can be **primary**. Setting an identifier to primary automatically unset the current primary identifier if exists. + +```csharp +await UAuthClient.Identifiers.SetMyPrimaryAsync(new SetPrimaryUserIdentifierRequest +{ + Id = identifierId +}); +``` + +๐Ÿ‘‰ Primary identifier is typically: + +- Used for display +- Preferred for communication + + +## ๐Ÿ” Login Identifiers + +Not all identifiers are used for login. + +๐Ÿ‘‰ **Login identifiers are a subset of identifiers** + +๐Ÿ‘‰ This is configurable: + +- Enable/disable per type +- Custom logic can be applied + +
+ +## ๐Ÿ“‹ Get My Identifiers + +```csharp +var result = await UAuthClient.Identifiers.GetMyAsync(); +``` + + +## โž• Add Identifier + +```csharp +await UAuthClient.Identifiers.AddMyAsync(new AddUserIdentifierRequest +{ + Identifier = "new@ultimateauth.com", + Type = UserIdentifierType.Email, + IsPrimary = true +}); +``` + +
+ +## โœ๏ธ Update Identifier + +```csharp +UpdateUserIdentifierRequest updateRequest = new() +{ + Id = item.Id, + NewValue = item.Value +}; + +await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); +``` + + +## โœ… Verify Identifier + +```csharp +await UAuthClient.Identifiers.VerifyMyAsync(new VerifyUserIdentifierRequest +{ + Id = identifierId +}); +``` + + +## โŒ Delete Identifier + +```csharp +await UAuthClient.Identifiers.DeleteMyAsync(new DeleteUserIdentifierRequest +{ + Id = identifierId +}); +``` + +
+ +## ๐Ÿ‘ค Admin Operations + +### Get User Identifiers + +```csharp +await UAuthClient.Identifiers.GetUserAsync(userKey); +``` + + +### Add Identifier to User + +```csharp +await UAuthClient.Identifiers.AddUserAsync(userKey, request); +``` + + +### Update Identifier + +```csharp +await UAuthClient.Identifiers.UpdateUserAsync(userKey, request); +``` + + +### Delete Identifier + +```csharp +await UAuthClient.Identifiers.DeleteUserAsync(userKey, request); +``` + + +## ๐Ÿ”„ State Events + +Identifier changes trigger events: + +- IdentifiersChanged + +๐Ÿ‘‰ Useful for: + +- UI updates +- Cache invalidation + +
+ +## ๐Ÿ” Security Considerations + +- Identifiers may require verification +- Login identifiers can be restricted +- Primary identifier can be controlled + + +## ๐ŸŽฏ Summary + +UltimateAuth identifiers: + +- are independent entities +- support multiple types +- separate login vs non-login identifiers +- are fully manageable via client diff --git a/docs/content/client/index.md b/docs/content/client/index.md new file mode 100644 index 00000000..a933839f --- /dev/null +++ b/docs/content/client/index.md @@ -0,0 +1,111 @@ +# ๐Ÿš€ Client Usage Guide + +UltimateAuth Client is a **high-level SDK** designed to simplify authentication flows. + +It is NOT just an HTTP wrapper. + +## ๐Ÿง  What Makes the Client Different? + +The client: + +- Handles full authentication flows (login, PKCE, refresh) +- Manages redirects automatically +- Publishes state events +- Provides structured results +- Works across multiple client types (SPA, server, hybrid) + +๐Ÿ‘‰ You SHOULD use the client instead of calling endpoints manually. + +
+ +## ๐Ÿงฑ Client Architecture + +The client is split into multiple specialized services: + +| Service | Responsibility | +|----------------------|-------------------------------------| +| FlowClient | Login, logout, refresh, PKCE | +| SessionClient | Session & device management | +| UserClient | User profile & lifecycle | +| IdentifierClient | Email / username / phone management | +| CredentialClient | Password management | +| AuthorizationClient | Roles & permissions | + +
+ +## ๐Ÿงฉ Client Entry Point + +UltimateAuth exposes a single entry point: + +๐Ÿ‘‰ `UAuthClient` + +```csharp +[Inject] IUAuthClient UAuthClient { get; set; } = null!; + +await UAuthClient.Flows.LoginAsync(...); +await UAuthClient.Users.GetMeAsync(); +await UAuthClient.Sessions.GetMyChainsAsync(); +``` + + +## ๐Ÿ”‘ Core Concept: Flow-Based Design + +UltimateAuth is **flow-oriented**, not endpoint-oriented. + +Instead of calling endpoints: + +โŒ POST /auth/login +โœ” flowClient.LoginAsync() + +
+ +## โšก Example + +```csharp +await UAuthClient.Flows.LoginAsync(new LoginRequest +{ + Identifier = "user@ultimateauth.com", + Secret = "password" +}); +``` + +๐Ÿ‘‰ This automatically: + +- Builds request payload +- Handles redirect +- Integrates with configured endpoints + +
+ +## ๐Ÿ”„ State Events + +Client automatically publishes events: + +- SessionRevoked +- ProfileChanged +- AuthorizationChanged + +
+ +## ๐Ÿงญ How to Use This Section + +Follow these guides: + +- authentication.md โ†’ login, refresh, logout +- session-management.md โ†’ sessions & devices +- user-management.md โ†’ user operations +- identifiers.md โ†’ login identifiers +- authorization.md โ†’ roles & permissions + +## ๐ŸŽฏ Summary + +UltimateAuth Client: + +- abstracts complexity +- enforces correct flows +- reduces security mistakes + + +๐Ÿ‘‰ Think of it as: + +**โ€œAuthentication runtime for your frontend / appโ€** diff --git a/docs/content/client/session-management.md b/docs/content/client/session-management.md new file mode 100644 index 00000000..d5885e9a --- /dev/null +++ b/docs/content/client/session-management.md @@ -0,0 +1,164 @@ +# ๐Ÿ“ฑ Session Management Guide + +This section explains how to manage sessions and devices using the UltimateAuth client. + +## ๐Ÿง  Overview + +In UltimateAuth, sessions are **not just tokens**. + +They are structured as: + +- Root โ†’ user-level security +- Chain โ†’ device (browser / mobile / app) +- Session โ†’ individual authentication instance + +๐Ÿ‘‰ On the client, you interact with: + +```csharp +[Inject] IUAuthClient UAuthClient { get; set; } = null!; + +await UAuthClient.Sessions... +``` + +## ๐Ÿ“‹ Get Active Sessions (Devices) + +```csharp +var result = await UAuthClient.Sessions.GetMyChainsAsync(); +``` + +๐Ÿ‘‰ Returns: + +- Active devices +- Session chains +- Metadata (device, timestamps) + +
+ +## ๐Ÿ” Get Session Detail + +```csharp +var detail = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); +``` + +๐Ÿ‘‰ Use this to: + +- Inspect a specific device +- View session history + +
+ +## ๐Ÿšช Logout vs Revoke (Important) + +UltimateAuth distinguishes between: + +### Logout + +```csharp +await UAuthClient.Flows.LogoutAsync(); +``` + +- Ends **current session** +- User can login again normally +- Does NOT affect other devices + +### Revoke (Session Control) + +```csharp +await UAuthClient.Sessions.RevokeMyChainAsync(chainId); +``` + +- Invalidates entire **device chain** +- All sessions under that device are revoked +- Cannot be restored +- New login creates a new chain + +๐Ÿ‘‰ Key difference: + +- Logout = end current session +- Revoke = destroy device identity + +For standard auth process, use `UAuthClient.Flows.LogoutMyDeviceAsync(chainId)` instead of `UAuthClient.Sessions.RevokeMyChainAsync(chainId)` + +
+ +## ๐Ÿ“ฑ Revoke Other Devices + +```csharp +await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); +``` + +๐Ÿ‘‰ This: + +- Keeps current device active +- Logs out all other devices + +
+ +## ๐Ÿ’ฅ Revoke All Sessions + +```csharp +await UAuthClient.Sessions.RevokeAllMyChainsAsync(); +``` + +๐Ÿ‘‰ This: + +- Logs out ALL devices (including current) +- Forces full reauthentication everywhere + +
+ +## ๐Ÿ‘ค Admin Session Management + +### Get User Devices + +```csharp +await UAuthClient.Sessions.GetUserChainsAsync(userKey); +``` + +### Revoke Specific Session + +```csharp +await UAuthClient.Sessions.RevokeUserSessionAsync(userKey, sessionId); +``` + +### Revoke Device (Chain) + +```csharp +await UAuthClient.Sessions.RevokeUserChainAsync(userKey, chainId); +``` + +### Revoke All User Sessions + +```csharp +await UAuthClient.Sessions.RevokeAllUserChainsAsync(userKey); +``` + +
+ +## ๐Ÿง  Device Model + +Each chain represents a **device identity**: + +- Browser instance +- Mobile device +- Application instance + +๐Ÿ‘‰ Sessions are grouped under chains. + +
+ +## ๐Ÿ” Security Implications + +Session operations are security-critical: + +- Revoke is irreversible +- Device isolation is enforced +- Cross-device attacks are contained + +## ๐ŸŽฏ Summary + +Session management in UltimateAuth: + +- is device-aware +- separates logout vs revoke +- gives full control over user sessions diff --git a/docs/content/client/user-management.md b/docs/content/client/user-management.md new file mode 100644 index 00000000..53df4f0a --- /dev/null +++ b/docs/content/client/user-management.md @@ -0,0 +1,175 @@ +# ๐Ÿ‘ค User Management Guide + +This section explains how to manage users using the UltimateAuth client. + +## ๐Ÿง  Overview + +User operations are handled via: + +```csharp +UAuthClient.Users... +``` + +This includes: + +- Profile management +- User lifecycle +- Admin operations + +
+ +## ๐Ÿ™‹ Get Current User + +```csharp +var me = await UAuthClient.Users.GetMeAsync(); +``` + +๐Ÿ‘‰ Returns: + +- User profile +- Status +- Basic identity data + +
+ +## โœ๏ธ Update Profile + +```csharp +await UAuthClient.Users.UpdateMeAsync(new UpdateProfileRequest +{ + DisplayName = "John Doe" +}); +``` + +๐Ÿ‘‰ Triggers: + +- Profile update +- State event (ProfileChanged) + +
+ +## โŒ Delete Current User + +```csharp +await UAuthClient.Users.DeleteMeAsync(); +``` + +๐Ÿ‘‰ This: + +- Deletes user (based on configured mode) +- Ends session +- Triggers state update + +
+ +## ๐Ÿ‘‘ Admin: Query Users + +```csharp +var result = await UAuthClient.Users.QueryAsync(new UserQuery +{ + Search = "john", + PageNumber = 1, + PageSize = 10 +}); +``` + +๐Ÿ‘‰ Supports: + +- search +- pagination +- filtering (status, etc.) + +
+ +## โž• Create User + +```csharp +await UAuthClient.Users.CreateAsync(new CreateUserRequest +{ + UserName = "john", + Email = "john@mail.com", + Password = "123456" +}); +``` + +## ๐Ÿ›  Admin Create + +```csharp +await UAuthClient.Users.CreateAsAdminAsync(request); +``` + +
+ +## ๐Ÿ”„ Change Status + +### Self + +```csharp +await UAuthClient.Users.ChangeMyStatusAsync(new ChangeUserStatusSelfRequest +{ + Status = UserStatus.SelfSuspended +}); +``` + +### Admin + +```csharp +await UAuthClient.Users.ChangeUserStatusAsync(userKey, new ChangeUserStatusAdminRequest +{ + Status = UserStatus.Suspended +}); +``` + +
+ +## โŒ Delete User (Admin) + +```csharp +await UAuthClient.Users.DeleteUserAsync(userKey, new DeleteUserRequest +{ + Mode = DeleteMode.Soft +}); +``` + +## ๐Ÿ” Get User + +```csharp +var user = await UAuthClient.Users.GetUserAsync(userKey); +``` + +## โœ๏ธ Update User (Admin) + +```csharp +await UAuthClient.Users.UpdateUserAsync(userKey, request); +``` + +## ๐Ÿง  Lifecycle Model + +Users have a lifecycle: + +- Active +- Suspended +- Disabled +- Deleted (soft/hard) + +๐Ÿ‘‰ Status impacts: + +- login ability +- session validity +- authorization + +
+ +## ๐Ÿ” Security Notes + +- Status changes may invalidate sessions +- Delete may trigger cleanup across domains +- Admin actions are policy-protected + +## ๐ŸŽฏ Summary + +User management in UltimateAuth: + +- is lifecycle-aware +- supports self + admin flows +- integrates with session & security model diff --git a/docs/content/configuration/advanced-configuration.md b/docs/content/configuration/advanced-configuration.md new file mode 100644 index 00000000..39b6815a --- /dev/null +++ b/docs/content/configuration/advanced-configuration.md @@ -0,0 +1,170 @@ +# ๐Ÿง  Advanced Configuration + +UltimateAuth is designed to be flexible โ€” but not fragile. + +๐Ÿ‘‰ You can customize almost every part of the system +๐Ÿ‘‰ Without breaking its security guarantees + +## โš ๏ธ Philosophy + +Customization in UltimateAuth follows one rule: + +๐Ÿ‘‰ You can extend behavior +๐Ÿ‘‰ You should not bypass security + +
+ +## ๐Ÿงฉ Extension Points + +UltimateAuth exposes multiple extension points: + +- Resolvers +- Validators +- Authorities +- Orchestrators +- Stores +- Events + +๐Ÿ‘‰ You donโ€™t replace the system +๐Ÿ‘‰ You plug into it + +
+ +## ๐Ÿ”Œ Replacing Services + +All core services can be overridden using DI: + +```csharp +services.AddScoped(); +``` + +๐Ÿ‘‰ This allows deep customization +๐Ÿ‘‰ While preserving the pipeline + +
+ +## ๐Ÿง  Authorities & Decisions + +Authorities are responsible for decisions: + +- LoginAuthority +- AccessAuthority + +--- + +You can override them: + +```csharp +services.AddScoped(); +``` + +๐Ÿ‘‰ This changes decision logic +๐Ÿ‘‰ Without touching flows + +
+ +## ๐Ÿ”„ Orchestrators + +Orchestrators coordinate execution: + +- Validate +- Authorize +- Execute command + +๐Ÿ‘‰ They enforce invariants +๐Ÿ‘‰ Do not bypass them, unless you exact know what you are doing + +
+ +## ๐Ÿ—„ Store Customization + +You can provide custom stores: + +- Session store +- Refresh token store +- User store + +๐Ÿ‘‰ Supports EF Core, in-memory, or custom implementations + +## ๐Ÿ“ก Events + +UltimateAuth provides event hooks: + +- Login +- Logout +- Refresh +- Revoke + +--- + +```csharp +o.Events.OnUserLoggedIn = ctx => +{ + // custom logic + return Task.CompletedTask; +}; +``` + +๐Ÿ‘‰ Use events for side-effects +๐Ÿ‘‰ Not for core logic + +
+ +## โš™๏ธ Mode Configuration Overrides + +You can customize behavior per mode: + +```csharp +o.ModeConfigurations[UAuthMode.Hybrid] = options => +{ + options.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5); +}; +``` + +๐Ÿ‘‰ Runs after defaults +๐Ÿ‘‰ Allows fine-grained control + +
+ +## ๐Ÿ” Custom Resolvers + +You can override how data is resolved: + +- Tenant resolver +- Session resolver +- Device resolver + +๐Ÿ‘‰ Enables full control over request interpretation + +## ๐Ÿ›ก Safety Boundaries + +UltimateAuth enforces: + +- Invariants +- Validation +- Fail-fast behavior + +๐Ÿ‘‰ Unsafe overrides will fail early + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Extend the system +๐Ÿ‘‰ Donโ€™t fight the system + +--- + +## ๐Ÿ“Œ Key Takeaways + +- Everything is replaceable via DI +- Authorities control decisions +- Orchestrators enforce flow +- Events are for side-effects +- Security boundaries are protected + +--- + +## โžก๏ธ Next Step + +Return to **Auth Flows** or explore **Plugin Domains** diff --git a/docs/content/configuration/client-options.md b/docs/content/configuration/client-options.md new file mode 100644 index 00000000..ce417097 --- /dev/null +++ b/docs/content/configuration/client-options.md @@ -0,0 +1,118 @@ +# ๐Ÿงฉ Client Options + +Client Options define how UltimateAuth behaves on the **client side**. + +๐Ÿ‘‰ While Server controls the system, +๐Ÿ‘‰ Client controls how it is **used** + +## ๐Ÿง  What Are Client Options? + +Client options configure: + +- Client profile (WASM, Server, MAUI, API) +- Endpoint communication +- PKCE behavior +- Token refresh +- Re-authentication behavior + +
+ +## โš™๏ธ Basic Usage + +```csharp +builder.Services.AddUltimateAuthClientBlazor(o => +{ + o.AutoDetectClientProfile = false; +}); +``` + +
+ +## ๐Ÿงฉ Client Profile + +UltimateAuth automatically detects client type by default: + +- Blazor WASM +- Blazor Server +- MAUI +- WebServer +- API + +You can override manually: + +```csharp +o.ClientProfile = UAuthClientProfile.BlazorWasm; +``` + +๐Ÿ‘‰ Manual override is useful for testing or special scenarios + +
+ +## ๐ŸŒ Endpoints + +Defines where requests are sent: + +```csharp +o.Endpoints.BasePath = "https://localhost:5001/auth"; +``` + +๐Ÿ‘‰ Required for WASM / remote clients + +
+ +## ๐Ÿ” PKCE Configuration + +Used for browser-based login flows: + +```csharp +o.Pkce.ReturnUrl = "https://localhost:5002/home"; +``` + +๐Ÿ‘‰ Required for WASM scenarios + +
+ +## ๐Ÿ” Auto Refresh + +Controls token/session refresh behavior: + +```csharp +o.AutoRefresh.Interval = TimeSpan.FromMinutes(1); +``` + +๐Ÿ‘‰ Keeps authentication alive automatically + +
+ +## ๐Ÿ”„ Re-authentication + +Controls behavior when session expires: + +```csharp +o.Reauth.Behavior = ReauthBehavior.RaiseEvent; +``` + +๐Ÿ‘‰ Allows silent or interactive re-login + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Server decides +๐Ÿ‘‰ Client adapts + +## ๐Ÿ“Œ Key Takeaways + +- Client options control runtime behavior +- Profile detection is automatic +- PKCE is required for public clients +- Refresh and re-auth are configurable +- Works together with Server options + +--- + +## โžก๏ธ Next Step + +Continue to **Configuration Sources** diff --git a/docs/content/configuration/configuration-overview.md b/docs/content/configuration/configuration-overview.md new file mode 100644 index 00000000..50767eb7 --- /dev/null +++ b/docs/content/configuration/configuration-overview.md @@ -0,0 +1,166 @@ +# ๐Ÿง  Configuration Overview + +UltimateAuth is not configured as a static system. + +๐Ÿ‘‰ It is configured as a **runtime-adaptive system** + +## โš ๏ธ A Common Misunderstanding + +Many frameworks expect you to configure authentication once: + +- Choose JWT or cookies +- Set token lifetimes +- Configure behavior globally + +๐Ÿ‘‰ And that configuration applies everywhere + +๐Ÿ‘‰ UltimateAuth does NOT work like this + +
+ +## ๐Ÿงฉ Layered Configuration Model + +UltimateAuth separates configuration into distinct layers: + +### ๐Ÿ”น Core (Behavior Definition) + +Core defines **what authentication means**: + +- Session lifecycle +- Token behavior +- PKCE rules +- Multi-tenant handling + +๐Ÿ‘‰ Core is the foundation + +๐Ÿ‘‰ But you typically do NOT configure it directly + +### ๐Ÿ”น Server (Application Configuration) + +Server is where you configure the system: + +```csharp +builder.Services.AddUltimateAuthServer(o => +{ + o.Login.MaxFailedAttempts = 5; +}); +``` + +This layer controls: + +- Allowed authentication modes +- Endpoint exposure +- Cookie behavior +- Security policies + +๐Ÿ‘‰ This is your main configuration surface + +### ๐Ÿ”น Client (Runtime Behavior) + +Client configuration controls: + +- Client profile (WASM, Server, MAUI, API) +- PKCE behavior +- Auto-refresh +- Re-authentication + +๐Ÿ‘‰ Client influences how flows are executed + +
+ +## โšก Runtime Configuration (The Key Idea) + +Here is the most important concept: + +๐Ÿ‘‰ UltimateAuth does NOT use configuration as-is + +Instead, it computes **Effective Configuration per request** + +### ๐Ÿง  How It Works + +At runtime: + +1. Client profile is detected +2. Flow type is determined (Login, Refresh, etc.) +3. Auth mode is resolved +4. Defaults are applied +5. Mode-specific overrides are applied + +๐Ÿ‘‰ This produces: + +``` +EffectiveOptions +``` + +
+ +## ๐Ÿ”„ From Static to Dynamic + +``` +UAuthServerOptions (startup) + โ†“ +Mode Resolver + โ†“ +Apply Defaults + โ†“ +Mode Overrides + โ†“ +EffectiveUAuthServerOptions (runtime) +``` + +๐Ÿ‘‰ Every request can have a different effective configuration + +
+ +## ๐ŸŽฏ Why This Matters + +This allows UltimateAuth to: + +- Use different auth modes per client +- Adapt behavior per flow +- Enforce security dynamically +- Avoid global misconfiguration + +๐Ÿ‘‰ You donโ€™t configure โ€œone systemโ€ + +๐Ÿ‘‰ You configure a **decision engine** + +
+ +## ๐Ÿ›ก Safety by Design + +Even with dynamic behavior: + +- Invalid combinations fail early +- Disallowed modes are rejected +- Security invariants are enforced + +๐Ÿ‘‰ Flexibility does not reduce safety + +
+ +## โš™๏ธ Core vs Effective + +| Concept | Meaning | +|-------------------|---------------------------------| +| Core Options | Base behavior definitions | +| Server Options | Application-level configuration | +| Effective Options | Runtime-resolved behavior | + +๐Ÿ‘‰ Effective options are what actually run + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ You donโ€™t configure authentication + +๐Ÿ‘‰ You configure how it is **resolved** + +## โžก๏ธ Next Step + +- Deep dive into behavior โ†’ Core Options +- Control runtime โ†’ Server Options +- Configure clients โ†’ Client Options diff --git a/docs/content/configuration/configuration-sources.md b/docs/content/configuration/configuration-sources.md new file mode 100644 index 00000000..964efc8e --- /dev/null +++ b/docs/content/configuration/configuration-sources.md @@ -0,0 +1,159 @@ +# โš™๏ธ Configuration Sources + +UltimateAuth supports multiple configuration sources. + +๐Ÿ‘‰ But more importantly, it defines a **clear and predictable precedence model** + +## ๐Ÿง  Two Ways to Configure + +UltimateAuth can be configured using: + +### ๐Ÿ”น Code (Program.cs) + +```csharp +builder.Services.AddUltimateAuthServer(o => +{ + o.Login.MaxFailedAttempts = 5; +}); +``` + +### ๐Ÿ”น Configuration Files (appsettings.json) + +```json +{ + "UltimateAuth": { + "Server": { + "Login": { + "MaxFailedAttempts": 5 + } + } + } +} +``` + +## โš–๏ธ Precedence Rules + +This is the most important rule: + +๐Ÿ‘‰ **Configuration files override code** + +Execution order: + +``` +Program.cs configuration + โ†“ +appsettings.json binding + โ†“ +Final options +``` + +๐Ÿ‘‰ This means: + +- Defaults can be defined in code +- Environments can override them safely + +
+ +## ๐ŸŒ Environment-Based Configuration + +ASP.NET Core supports environment-specific configuration: + +- appsettings.Development.json +- appsettings.Staging.json +- appsettings.Production.json + +Example: + +```json +{ + "UltimateAuth": { + "Server": { + "Session": { + "IdleTimeout": "7.00:00:00" + } + } + } +} +``` + +๐Ÿ‘‰ You can use different values per environment without changing code + +
+ +## ๐Ÿงฉ Recommended Strategy + +For real-world applications: + +### โœ” Use Program.cs for: +- Defaults +- Development setup +- Local testing + +### โœ” Use appsettings for: +- Environment-specific overrides +- Production tuning +- Deployment configuration + +๐Ÿ‘‰ This keeps your system flexible and maintainable + +
+ +## ๐Ÿ›ก Safety & Validation + +UltimateAuth validates configuration at startup: + +- Invalid combinations are rejected +- Missing required values fail fast +- Unsafe configurations are blocked + + +๐Ÿ‘‰ You will not run with a broken configuration + + +## โš ๏ธ Common Pitfalls + +### โŒ Assuming code overrides config + +It does not. + +๐Ÿ‘‰ appsettings.json always wins + +### โŒ Hardcoding production values + +Avoid: + +```csharp +o.Token.AccessTokenLifetime = TimeSpan.FromHours(1); +``` + +๐Ÿ‘‰ Use environment config instead for maximum flexibility + + +### โŒ Mixing environments unintentionally + +Ensure correct environment is set: + +``` +ASPNETCORE_ENVIRONMENT=Production +``` + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Code defines defaults +๐Ÿ‘‰ Configuration defines reality + +## ๐Ÿ“Œ Key Takeaways + +- UltimateAuth supports code + configuration +- appsettings.json overrides Program.cs +- Environment-based configuration is first-class +- Validation prevents unsafe setups +- Designed for real-world deployment + +## โžก๏ธ Next Step + +Continue to **Advanced Configuration** diff --git a/docs/content/configuration/index.md b/docs/content/configuration/index.md new file mode 100644 index 00000000..534552eb --- /dev/null +++ b/docs/content/configuration/index.md @@ -0,0 +1,107 @@ +# โš™๏ธ Configuration & Extensibility + +UltimateAuth is designed to be flexible. + +But flexibility without structure leads to chaos. + +๐Ÿ‘‰ Configuration in UltimateAuth is structured, layered, and safe by default. + +## ๐Ÿง  What You Configure + +In UltimateAuth, you donโ€™t just configure values. + +๐Ÿ‘‰ You configure behavior. + +This includes: + +- How sessions are created and managed +- How tokens are issued and refreshed +- How tenants are resolved +- How clients interact with the system +- Which features are enabled or restricted + +
+ +## ๐Ÿงฉ Configuration Layers + +UltimateAuth separates configuration into three layers: + +### ๐Ÿ”น Core + +Defines authentication behavior: + +- Session lifecycle +- Token policies +- PKCE flows +- Multi-tenancy + +### ๐Ÿ”น Server + +Defines runtime behavior: + +- Allowed authentication modes +- Endpoint exposure +- Cookie and transport behavior +- Hub deployment + +### ๐Ÿ”น Client + +Defines client-side behavior: + +- Client profile +- PKCE configuration +- Auto-refresh and re-authentication + +๐Ÿ‘‰ These layers are independent but work together. + +## โš™๏ธ Configuration Sources + +UltimateAuth supports two configuration styles: + +### Code-based (Program.cs) + +```csharp +builder.Services.AddUltimateAuthServer(o => +{ + o.Login.MaxFailedAttempts = 5; +}); +``` + +### Configuration-based (appsettings.json) +```csharp +{ + "UltimateAuth": { + "Server": { + "Login": { + "MaxFailedAttempts": 5 + } + } + } +} +``` + +๐Ÿ‘‰ appsettings.json overrides Program.cs + +This allows: + +- Environment-based configuration +- Centralized management +- Production-safe overrides + +## ๐Ÿ›ก Safety by Design + +UltimateAuth does not allow unsafe configurations silently. + +- Invalid combinations fail at startup +- Unsupported modes are rejected +- Security invariants are enforced + +๐Ÿ‘‰ Flexibility is allowed +๐Ÿ‘‰ Unsafe behavior is not + +## ๐ŸŽฏ Whatโ€™s Next? +- Understand configuration layers โ†’ Configuration Overview +- Learn Core behavior โ†’ Core Options +- Customize server โ†’ Server Options +- Control clients โ†’ Client Options +- Go deeper โ†’ Advanced Configuration diff --git a/docs/content/configuration/server-options.md b/docs/content/configuration/server-options.md new file mode 100644 index 00000000..1ad00e20 --- /dev/null +++ b/docs/content/configuration/server-options.md @@ -0,0 +1,207 @@ +# ๐Ÿงฉ Server Options + +UltimateAuth is configured primarily through **Server Options**. + +๐Ÿ‘‰ This is the main entry point for configuring authentication behavior. + +## ๐Ÿง  What Are Server Options? + +Server options define how UltimateAuth behaves **inside your application**. + +They control: + +- Authentication behavior +- Security policies +- Token issuance +- Session lifecycle +- Endpoint exposure + +
+ +## โš™๏ธ Basic Usage + +You configure server options in `Program.cs`: + +```csharp +builder.Services.AddUltimateAuthServer(o => +{ + o.Login.MaxFailedAttempts = 5; + o.Session.IdleTimeout = TimeSpan.FromDays(7); +}); +``` + +--- + +You can also use `appsettings.json`: + +```json +{ + "UltimateAuth": { + "Server": { + "Login": { + "MaxFailedAttempts": 5 + }, + "Session": { + "IdleTimeout": "07.00.00.00" + } + } + } +} +``` + +๐Ÿ‘‰ `appsettings.json` overrides `Program.cs` + +## ๐Ÿงฉ Core Composition + +Server options include Core behavior: + +- Login +- Session +- Token +- PKCE +- Multi-tenancy + +๐Ÿ‘‰ These are defined in Core + +๐Ÿ‘‰ But configured via Server + +
+ +## โš ๏ธ Important: You Donโ€™t Configure Modes Directly + +UltimateAuth does NOT expect you to select a single auth mode. + +Instead: + +๐Ÿ‘‰ Mode is resolved at runtime + +
+ +## ๐Ÿ›ก Allowed Modes (Guardrail) + +```csharp +o.AllowedModes = new[] +{ + UAuthMode.Hybrid, + UAuthMode.PureOpaque +}; +``` + +๐Ÿ‘‰ This does NOT select a mode +๐Ÿ‘‰ It restricts which modes are allowed + +If a resolved mode is not allowed: + +๐Ÿ‘‰ Request fails early + +
+ +## โšก Runtime Behavior (Effective Options) + +Server options are not used directly. + +They are transformed into: + +๐Ÿ‘‰ `EffectiveUAuthServerOptions` + +This happens per request: + +- Mode is resolved +- Defaults are applied +- Overrides are applied + +๐Ÿ‘‰ What actually runs is **Effective Options** + +
+ +## ๐Ÿ”„ Mode-Based Defaults + +Each auth mode applies different defaults automatically: + +- PureOpaque โ†’ session-heavy +- Hybrid โ†’ session + token +- PureJwt โ†’ token-only + + +๐Ÿ‘‰ You donโ€™t need to manually configure everything + +
+ +## ๐ŸŽ› Endpoint Control + +You can control which features are enabled: + +```csharp +o.Endpoints.Authentication = true; +o.Endpoints.Session = true; +o.Endpoints.Authorization = true; +``` + + +You can also disable specific actions: + +```csharp +o.Endpoints.DisabledActions.Add("UAuthActions.Users.Create.Anonymous"); +``` + +๐Ÿ‘‰ Useful for API hardening + +
+ +## ๐Ÿช Cookie & Transport Behavior + +Server options define how credentials are transported: + +- Cookies +- Headers +- Tokens + +๐Ÿ‘‰ Unsafe combinations are rejected at startup + +
+ +## ๐ŸŒ Hub Configuration + +If using UAuthHub: + +```csharp +o.HubDeploymentMode = UAuthHubDeploymentMode.Integrated; +``` + +๐Ÿ‘‰ Defines how auth server is deployed + +
+ +## ๐Ÿ” Session Resolution + +Controls how session IDs are extracted: + +- Cookie +- Header +- Bearer +- Query + +๐Ÿ‘‰ Fully configurable + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Server options define **what is allowed** +๐Ÿ‘‰ Runtime determines **what is used** + +## ๐Ÿ“Œ Key Takeaways + +- Server options are the main configuration entry +- Core behavior is configured via server +- Modes are not selected manually +- Effective options are computed per request +- Security is enforced by design + +--- + +## โžก๏ธ Next Step + +Continue to **Client Options** diff --git a/docs/content/fundamentals/auth-model.md b/docs/content/fundamentals/auth-model.md new file mode 100644 index 00000000..b5026353 --- /dev/null +++ b/docs/content/fundamentals/auth-model.md @@ -0,0 +1,182 @@ +# ๐Ÿง  Authentication Model + +UltimateAuth is built around a simple but powerful idea: + +> Authentication is not just a token. +> It is a **structured, server-controlled session model**. + +At the core of this model are three concepts: + +- **Root** +- **Chain** +- **Session** + +Together, they form what we call: + +๐Ÿ‘‰ **Authentication Lineage** + +
+ +## ๐Ÿ”‘ The Big Picture + +Instead of treating authentication as a single token or cookie, UltimateAuth models it as a hierarchy: + +``` +Root (user authority) +โ”œโ”€โ”€ Chain (device context) +โ”‚ โ”œโ”€โ”€ Session (login instance) +โ”‚ โ”œโ”€โ”€ Session +โ”‚ +โ”œโ”€โ”€ Chain +โ”‚ โ”œโ”€โ”€ Session (login instance) +``` + +Each level has a distinct responsibility. + +
+ +## ๐Ÿงฉ Root โ€” The Authority + +**Root** represents the authentication authority of a user within a tenant. + +- There is **only one active Root per user per tenant** +- It defines the **global security state** +- It controls all chains and sessions + +### What Root Does + +- Tracks security version (security epoch) +- Invalidates all sessions when needed +- Acts as the **source of truth** + +### Example + +If a user changes their password: + +๐Ÿ‘‰ Root is updated +๐Ÿ‘‰ All existing sessions can become invalid + +
+ +## ๐Ÿ”— Chain โ€” The Device Context + +**Chain** represents a device or client context. + +- Each device typically has its own chain +- Multiple logins from the same device belong to the same chain + +๐Ÿ‘‰ Think of Chain as: + +> โ€œWhere is the user logged in from?โ€ + +### What Chain Does + +- Groups sessions by device +- Enables **device-level control** +- Allows actions like: + + - Logout from one device + - Revoke a specific device + +
+ +## ๐Ÿงพ Session โ€” The Authentication Instance + +**Session** is the actual authentication instance. + +- Created when the user logs in +- Represents a single authenticated state +- Carries a snapshot of the Root security version + +๐Ÿ‘‰ This is what gets validated on each request. + +### What Session Does + +- Proves the user is authenticated +- Can be refreshed, revoked, or expired +- Is tied to a specific chain + +
+ +## ๐Ÿ”„ How They Work Together + +When a user logs in: + +1. Root is resolved (or created) +2. A Chain is identified (device context) +3. A new Session is created + +`Login โ†’ Root โ†’ Chain โ†’ Session` + + +On each request: + +1. Session is validated +2. Chain context is checked +3. Root security version is verified + +๐Ÿ‘‰ If any level is invalid โ†’ authentication fails + +
+ +## ๐Ÿ›ก Why This Model Matters + +Traditional systems rely on: + +- Cookies +- JWT tokens +- Stateless validation + +These approaches have limitations: + +- No real session control +- Weak revocation +- No device awareness + +--- + +UltimateAuth solves this by: + +### โœ” Server-Controlled Authentication + +- Sessions are always validated server-side +- No blind trust in tokens + +### โœ” Instant Revocation + +- Revoke a session โ†’ immediate effect +- Revoke a chain โ†’ device logged out +- Revoke root โ†’ disable user (global logout) + +### โœ” Device Awareness + +- Each device has its own chain +- Sessions are bound to context + +### โœ” Strong Security Model + +- Session carries Root security version +- Old sessions automatically become invalid + +
+ +## ๐Ÿง  Mental Model + +If you remember only one thing, remember this: + +๐Ÿ‘‰ **Root = authority** +๐Ÿ‘‰ **Chain = device** +๐Ÿ‘‰ **Session = login** + +## ๐Ÿ“Œ Key Takeaways + +- Authentication is not just a token +- Sessions are first-class citizens +- The server always remains in control +- Device and security context are built-in + +## โžก๏ธ Next Step + +Now that you understand the core model: + +๐Ÿ‘‰ Continue to **Flow-Based Authentication** diff --git a/docs/content/fundamentals/auth-modes.md b/docs/content/fundamentals/auth-modes.md new file mode 100644 index 00000000..941d14e3 --- /dev/null +++ b/docs/content/fundamentals/auth-modes.md @@ -0,0 +1,160 @@ +> Note: SemiHybrid and PureJwt modes will be available on future releases. For now you can safely use PureOpaque and Hybrid modes. + +# ๐Ÿ” Authentication Modes + +UltimateAuth supports multiple authentication modes. + +Each mode represents a different balance between: + +- Security +- Performance +- Control +- Client capabilities + +๐Ÿ‘‰ You donโ€™t always choose a single model. +UltimateAuth can adapt based on context. + +
+ +## ๐Ÿงฉ Available Modes + +### PureOpaque +Fully server-controlled session model. + +### Hybrid +Combines session control with token-based access. + +### SemiHybrid +JWT-based with server-side metadata awareness. + +### PureJwt +Fully stateless token-based authentication. + +
+ +## โš–๏ธ Mode Comparison + +| Feature | PureOpaque | Hybrid | SemiHybrid | PureJwt | +|----------------|------------|------------|----------------|--------------| +| SessionId | Required | Required | Metadata only | None | +| Access Token | โŒ | โœ” | โœ” | โœ” | +| Refresh Token | โŒ | โœ” | โœ” | โœ” | +| Revocation | Immediate | Immediate | Metadata-based | Not immediate| +| Statefulness | Full | Hybrid | Semi | Stateless | +| Server Control | Full | High | Medium | Low | +| Performance* | Medium | Medium | High | Highest | +| Offline Support| โŒ | Partial | โœ” | โœ” | + +> โšก **Performance Note** +> +> All modes in UltimateAuth are designed for production use and are highly optimized. +> +> The differences here are about **trade-offs**, not absolute speed: +> +> ๐Ÿ‘‰ Even the most server-controlled mode is performant enough for real-world applications. + +
+ +## ๐Ÿง  How to Think About Auth Modes +Itโ€™s important to understand that authentication modes in UltimateAuth are not rigid system-wide choices. + +๐Ÿ‘‰ You are not expected to pick a single mode and use it everywhere. + +Instead: + +- Different clients can use different modes on a single UAuthHub +- The mode can change **per request** +- UltimateAuth selects the most appropriate mode based on **Client Profile and runtime context** + +
+ +### ๐Ÿ”„ Runtime-Driven Behavior +In a typical application: + +- Blazor Server โ†’ PureOpaque +- Blazor WASM โ†’ Hybrid +- API โ†’ PureJwt + +๐Ÿ‘‰ All can coexist in the same system. + +You donโ€™t split your architecture โ€” UltimateAuth adapts automatically. + +### โš™๏ธ You Can Override Everything +UltimateAuth provides **safe defaults**, but nothing is locked. + +You can: + +- Force a specific auth mode +- Customize behavior per client +- Replace default strategies + +๐Ÿ‘‰ The system is designed to be flexible without sacrificing safety. + +### ๐Ÿ›ก Safe by Default +The comparison table shows trade-offs โ€” not risks. + +- All modes are **valid and supported** +- Choosing a different mode will not โ€œbreakโ€ security +- Incompatible configurations will **fail fast** + +๐Ÿ‘‰ You are always operating within a safe boundary. + +### ๐Ÿ’ก Mental Model + +Think of auth modes as: + +> Different execution strategies โ€” not different systems. + +UltimateAuth remains consistent. +Only the **behavior adapts**. + +
+ +## ๐Ÿ” PureOpaque +- Fully session-based +- Every request validated on server +- Maximum security +- Touch semantics instead of refresh rotation + +๐Ÿ‘‰ Best for: + +- Blazor Server +- Internal apps + +## โšก Hybrid +- Access token as opaque session id +- Refresh token with rotate semantics +- Server control with API performance + +๐Ÿ‘‰ Best for: + +- Blazor WASM +- Web + API systems +- Full-stack apps + +## ๐Ÿš€ SemiHybrid +- JWT-based access +- Server-side metadata control + +๐Ÿ‘‰ Best for: + +- High-performance APIs +- Zero-trust systems + +## ๐ŸŒ PureJwt +- Fully stateless +- No server-side session control + +๐Ÿ‘‰ Best for: + +- External APIs +- Microservices + +## ๐ŸŽฏ Which Mode Should You Use? + +| Scenario | Recommended Mode | +|------------------------|------------------| +| Blazor Server | PureOpaque | +| Web + API | Hybrid | +| High-scale API | SemiHybrid | +| External microservices | PureJwt | diff --git a/docs/content/fundamentals/client-profiles.md b/docs/content/fundamentals/client-profiles.md new file mode 100644 index 00000000..7c4387fa --- /dev/null +++ b/docs/content/fundamentals/client-profiles.md @@ -0,0 +1,144 @@ +# ๐Ÿงฉ Client Profiles +UltimateAuth adapts its authentication behavior based on the client. + +๐Ÿ‘‰ This is powered by **Client Profiles**. + +
+ +## ๐Ÿ”‘ What is a Client Profile? +A Client Profile defines how authentication behaves for a specific client type. + +It affects: + +- Authentication mode +- Flow behavior +- Token usage +- Security constraints + +Unlike traditional systems: + +๐Ÿ‘‰ You donโ€™t configure authentication globally +๐Ÿ‘‰ You let the system adapt per client + +## ๐Ÿง  The Key Idea +> Authentication behavior is not static +> It is determined **per client and per request** + +
+ +## ๐Ÿ”„ Runtime Detection +By default, UltimateAuth automatically detects the client profile. + +This is done using runtime signals such as: + +- Loaded assemblies +- Hosting environment +- Registered services + +### Example Detection Logic +- MAUI assemblies โ†’ `Maui` +- WebAssembly runtime โ†’ `BlazorWasm` +- Server components โ†’ `BlazorServer` +- UAuthHub marker โ†’ `UAuthHub` +- Fallback โ†’ `WebServer` + +๐Ÿ‘‰ Detection happens inside the client at startup. + +
+ +## ๐Ÿ“ก Client โ†’ Server Propagation +The detected client profile is sent to the server on each request. + +This can happen via: + +- Request headers +- Form fields (for flow-based operations) + +```text +Client โ†’ (ClientProfile) โ†’ Server +``` +On the server: + +- The profile is read from the request +- If not provided โ†’ NotSpecified +- Server applies defaults or resolves behavior + +
+ +## โš™๏ธ Automatic vs Manual Configuration +### Automatic (Default) +```csharp +builder.Services.AddUltimateAuthClientBlazor(); +``` +It means: +- AutoDetectClientProfile = true +- Profile is resolved automatically + +### Manual Override +You can explicitly set the client profile: +```csharp +builder.Services.AddUltimateAuthClientBlazor(o => +{ + o.ClientProfile = UAuthClientProfile.Maui; + o.AutoDetectClientProfile = false; // optional +}); +``` + +๐Ÿ‘‰ This is useful when: +- Running in custom hosting environments +- Detection is ambiguous +- You want full control + +
+ +## ๐Ÿงฉ Built-in Profiles + +UltimateAuth includes predefined profiles: +| Profile | Description | +| ------------ | ---------------------------- | +| BlazorServer | Server-rendered apps | +| BlazorWasm | Browser-based WASM apps | +| Maui | Native mobile apps | +| WebServer | MVC / Razor / generic server | +| Api | API-only backend | +| UAuthHub | Central auth server | + +### ๐Ÿ›ก Safe Defaults +If no profile is specified (and auto detection is false): + +- Client โ†’ NotSpecified โ†’ Server resolves safely +- Defaults are applied +- Unsafe combinations are prevented +- System remains consistent + +### ๐Ÿ” Why This Matters +Client Profiles enable: + +- Multi-client systems (Web + Mobile + API) +- Runtime adaptation +- Safe defaults without manual configuration + +Without Client Profiles You would need: +- Separate auth setups per client +- Complex branching logic +- Manual security handling + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Client defines behavior +๐Ÿ‘‰ Server enforces rules + +## ๐Ÿ“Œ Key Takeaways +- Client Profiles are automatically detected +- They are sent to the server on each request +- Behavior adapts per request +- You can override everything when needed +- Safe defaults are always applied + +## โžก๏ธ Next Step + +Now that you understand runtime behavior: + +๐Ÿ‘‰ Continue to Runtime Architecture diff --git a/docs/content/fundamentals/flow-based-auth.md b/docs/content/fundamentals/flow-based-auth.md new file mode 100644 index 00000000..ba4081dc --- /dev/null +++ b/docs/content/fundamentals/flow-based-auth.md @@ -0,0 +1,198 @@ +# ๐Ÿ”„ Flow-Based Authentication +UltimateAuth is not cookie-based or token-based. + +๐Ÿ‘‰ It is **flow-based**. + +
+ +## ๐Ÿ”‘ What Does โ€œFlow-Basedโ€ Mean? +In traditional systems, authentication is treated as: + +- A cookie +- A JWT token + +Once issued, the system simply checks: + +> โ€œIs this token valid?โ€ + +
+ +UltimateAuth takes a different approach: + +๐Ÿ‘‰ Authentication is a **series of controlled flows**, not a static artifact. + +
+ +## ๐Ÿงญ Authentication as Flows +Every authentication operation is an explicit flow: + +- **Login** +- **Logout** +- **Validate** +- **Refresh** +- **Re-authentication** + +Each flow: + +- Is initiated intentionally +- Is processed on the server +- Produces a controlled result + +
+ +## ๐Ÿ” Example: Login Flow +Instead of: + +> โ€œGenerate a token and store itโ€ + +UltimateAuth does: +``` +Login Request +โ†’ Validate credentials +โ†’ Resolve Root +โ†’ Resolve or create Chain +โ†’ Create Session +โ†’ Issue authentication grant +``` + +๐Ÿ‘‰ Login is not a single step โ€” it is a **managed process** + +
+ +## ๐Ÿ”„ Example: Refresh Flow +Traditional systems: + +> Refresh = issue new token + +UltimateAuth: +``` +Refresh Request +โ†’ Validate session +โ†’ Check security constraints +โ†’ Apply policies (if any) +โ†’ Optionally rotate tokens +โ†’ Update session state (if needed) +``` + +๐Ÿ‘‰ The server decides what actually happens + +
+ +## ๐Ÿ” Example: Validate Flow +On each request: +``` +Incoming Request +โ†’ Extract session/token +โ†’ Validate session +โ†’ Check chain (device context) +โ†’ Verify root security version +โ†’ Build auth state +``` + +๐Ÿ‘‰ Validation is not just โ€œtoken valid?โ€ + +
+ +## โš ๏ธ Why Token-Based Thinking Falls Short +Token-based systems assume: + +- The token contains truth +- The server trusts the token + +This leads to: + +- Weak revocation +- No device awareness +- Limited control + +
+ +## โœ… UltimateAuth Approach +UltimateAuth treats tokens as: + +๐Ÿ‘‰ **transport artifacts**, not sources of truth + +The real authority is: + +- Root +- Chain +- Session + +
+ +## ๐Ÿง  Key Idea +> Tokens carry data +> Flows enforce rules + +
+ +## ๐Ÿ” Server-Controlled by Design + +All flows are: + +- Executed on the server +- Evaluated against policies +- Subject to security constraints + +๐Ÿ‘‰ The client does not control authentication state + +
+ +## โš™๏ธ Flow Examples in Code + +Using `IUAuthClient`: + +```csharp +await UAuthClient.Flows.LoginAsync(request); +await UAuthClient.Flows.RefreshAsync(); +await UAuthClient.Flows.LogoutAsync(); +``` +๐Ÿ‘‰ Each method represents a server-driven flow + +
+ +## ๐Ÿงฉ How This Changes Development +Instead of thinking: + +โŒ โ€œI need to manage tokensโ€ + +You think: + +โœ… โ€œI need to trigger flowsโ€ + +
+ +## ๐Ÿ“Œ Benefits of Flow-Based Authentication +### โœ” Predictable Behavior +- Every action is explicit and controlled. + +### โœ” Better Security +- No blind token trust +- Server-side validation +- Policy-driven decisions + +### โœ” Extensibility +Flows can be extended with: + +- MFA +- Risk-based checks +- Custom policies + +### โœ” Consistent Across Clients +Same flows work for: +- Blazor Server +- WASM (PKCE) +- APIs + +
+ +## ๐Ÿง  Mental Model +If you remember one thing: + +๐Ÿ‘‰ Authentication is not a token โ€” it is a process + +## โžก๏ธ Next Step + +Now that you understand flows: + +๐Ÿ‘‰ Continue to Auth Modes diff --git a/docs/content/fundamentals/index.md b/docs/content/fundamentals/index.md new file mode 100644 index 00000000..cd2cfed7 --- /dev/null +++ b/docs/content/fundamentals/index.md @@ -0,0 +1,12 @@ +# ๐Ÿง  Fundamentals + +This section explains how UltimateAuth works internally. + +If you are new, follow this order: + +1. [Authentication Model](./auth-model.md) +2. [Flow-Based Authentication](./flow-based-auth.md) +3. [Authentication Modes](./auth-modes.md) +4. [Client Profiles](./client-profiles.md) +5. [Runtime Architecture](./runtime-architecture.md) +6. [Request Lifecycle](./request-lifecycle.md) diff --git a/docs/content/fundamentals/request-lifecycle.md b/docs/content/fundamentals/request-lifecycle.md new file mode 100644 index 00000000..4cf2cf34 --- /dev/null +++ b/docs/content/fundamentals/request-lifecycle.md @@ -0,0 +1,156 @@ +# ๐Ÿ”„ Request Lifecycle +This section explains what happens when a request enters UltimateAuth. + +๐Ÿ‘‰ From the moment an HTTP request arrives +๐Ÿ‘‰ Until authentication state is established or a flow is executed + +
+ +## ๐Ÿง  Two Types of Requests +UltimateAuth processes requests in two different ways: + +### 1. Passive Requests +Regular application requests (page load, API call) + +### 2. Active Flow Requests +Authentication flows (login, refresh, logout) + +๐Ÿ‘‰ Both share the same foundation, but diverge at the flow level. + +
+ +## ๐Ÿงฉ Middleware Pipeline +Every request passes through the UltimateAuth middleware pipeline: + +``` +Tenant โ†’ Session Resolution โ†’ (Validation) โ†’ User Resolution +``` + +### ๐Ÿข Tenant Resolution +The system determines the tenant: + +- Multi-tenant โ†’ resolved via `ITenantResolver` +- Single-tenant โ†’ default context applied + +๐Ÿ‘‰ If tenant cannot be resolved โ†’ request fails early + +### ๐Ÿ” Session Resolution +The system attempts to extract a session: + +- From headers, cookies, or tokens +- Converted into a `SessionContext` + +``` +SessionId โ†’ SessionContext +``` + +๐Ÿ‘‰ If no session is found โ†’ request becomes anonymous + +### โœ” Session Validation (Resource APIs) +For API scenarios: + +- Session is validated immediately +- Device context is considered +- A validation result is attached to the request + +๐Ÿ‘‰ This enables stateless or semi-stateful validation + +### ๐Ÿ‘ค User Resolution +The system resolves the current user: + +- Based on validated session +- Using `IUserAccessor` + +๐Ÿ‘‰ This produces the final user context + +
+ +## ๐Ÿ”„ Passive Request Flow +For normal requests: + +``` +Request +โ†’ Middleware pipeline +โ†’ Session resolved +โ†’ User resolved +โ†’ Application executes +``` + +๐Ÿ‘‰ No flow execution happens + +
+ +## ๐Ÿ” Active Flow Requests +For auth endpoints (login, refresh, etc.): + +The lifecycle continues beyond middleware. + +### Step 1: Flow Detection +``` +Endpoint โ†’ FlowType +``` + +### Step 2: Context Creation +An `AuthFlowContext` is created. + +It includes: + +- Client profile +- Effective mode +- Tenant +- Device +- Session +- Response configuration + +๐Ÿ‘‰ This defines the execution environment + +### Step 3: Flow Execution +``` +AuthFlowContext โ†’ Flow Service โ†’ Orchestrator โ†’ Authority +``` + +### Step 4: State Mutation +Depending on the flow: + +- Session may be created, updated, or revoked +- Tokens may be issued +- Security state may change + +### Step 5: Response Generation +The system writes the response: + +- SessionId +- Access token +- Refresh token +- Redirect (if needed) + +
+ +## ๐Ÿ” Example: Login Request +``` +HTTP Request +โ†’ Tenant resolved +โ†’ Session resolved (anonymous) +โ†’ Flow detected (Login) +โ†’ AuthFlowContext created +โ†’ Credentials validated +โ†’ Session created +โ†’ Tokens issued +โ†’ Response returned +``` + +
+ +## ๐Ÿ” Flow Execution Boundary +Authentication flows are only executed for endpoints explicitly marked with flow metadata. + +- Regular requests do not create an AuthFlowContext +- AuthFlowContext is only created during flow execution + +๐Ÿ‘‰ This ensures that authentication logic does not interfere with normal application behavior. + +
+ +## ๐Ÿง  Mental Model +๐Ÿ‘‰ Middleware prepares the request +๐Ÿ‘‰ Flows change the state diff --git a/docs/content/fundamentals/runtime-architecture.md b/docs/content/fundamentals/runtime-architecture.md new file mode 100644 index 00000000..8d857469 --- /dev/null +++ b/docs/content/fundamentals/runtime-architecture.md @@ -0,0 +1,158 @@ +# ๐Ÿ— Runtime Architecture + +UltimateAuth processes authentication through a structured execution pipeline. + +๐Ÿ‘‰ It is not just middleware-based authentication +๐Ÿ‘‰ It is a **flow-driven execution system** + +## ๐Ÿง  The Big Picture + +When a request reaches an auth endpoint: + +```text +Request + โ†’ Endpoint Filter + โ†’ AuthFlowContext + โ†’ Endpoint Handler + โ†’ Flow Service + โ†’ Orchestrator + โ†’ Authority + โ†’ Stores / Issuers + โ†’ Response +``` +Each step has a clearly defined responsibility. + +## ๐Ÿ”„ Request Entry Point +Authentication begins at the endpoint level. + +An endpoint filter detects the flow: + +`Endpoint โ†’ FlowType (Login, Refresh, Logoutโ€ฆ)` + +๐Ÿ‘‰ The system knows which flow is being executed before any logic runs. + +
+ +## ๐Ÿงพ AuthFlowContext โ€” The Core State +Before any operation starts, UltimateAuth creates an AuthFlowContext. + +This is the central object that carries: + +- Client profile +- Effective authentication mode +- Tenant information +- Device context +- Session state +- Response configuration + +๐Ÿ‘‰ This context defines the entire execution environment + +
+ +## โš™๏ธ Flow Service โ€” Entry Layer +After the context is created, the request is passed to the Flow Service. + +The Flow Service: + +- Acts as the entry point for all flows +- Normalizes execution +- Delegates work to orchestrators + +๐Ÿ‘‰ It does not implement business logic directly + +
+ +## ๐Ÿงญ Orchestrator โ€” Flow Coordinator +The Orchestrator manages the execution of a flow. + +- Coordinates multiple steps +- Ensures correct execution order +- Delegates decisions to Authority + +๐Ÿ‘‰ Think of it as the flow engine + +
+ +## ๐Ÿ” Authority โ€” Decision Layer +The Authority is the most critical component. + +- Validates authentication state +- Applies security rules +- Approves or rejects operations + +๐Ÿ‘‰ No sensitive operation bypasses Authority + +
+ +## โš™๏ธ Services & Stores +Once decisions are made: + +- Services handle domain logic +- Stores handle persistence +- Issuers generate tokens or session artifacts + +๐Ÿ‘‰ These layers do not make security decisions + +
+ +## ๐Ÿ” End-to-End Example (Login) +Login Request +``` + โ†’ Endpoint Filter (Login Flow) + โ†’ AuthFlowContext created + โ†’ Flow Service + โ†’ Orchestrator + โ†’ Authority validates credentials + โ†’ Session created (Store) + โ†’ Tokens issued (Issuer) + โ†’ Response generated +``` +
+ +## ๐Ÿง  Why This Architecture Matters +โœ” Centralized Decision Making +- Authority is always in control +- No scattered validation logic + +โœ” Predictable Execution +- Every flow follows the same pipeline +- No hidden behavior + +โœ” Extensibility +- Replace stores +- Extend flows +- Customize orchestration + +โœ” Security by Design +- No bypass of Authority +- Context-driven validation +- Flow-aware execution + +## ๐Ÿ”— Relation to Other Concepts +This architecture connects all previous concepts: + +- Auth Model (Root / Chain / Session) โ†’ validated in Authority +- Auth Flows โ†’ executed by Orchestrator +- Auth Modes โ†’ applied via EffectiveMode +- Client Profiles โ†’ influence behavior at runtime + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Flow defines what happens +๐Ÿ‘‰ Context defines how it happens +๐Ÿ‘‰ Authority decides if it happens + +## ๐Ÿ“Œ Key Takeaways +Authentication is executed as a pipeline +AuthFlowContext carries execution state +Orchestrator coordinates flows +Authority enforces security +Services and Stores execute operations + +## โžก๏ธ Next Step + +Now that you understand the execution model: + +๐Ÿ‘‰ Continue to Request Lifecycle diff --git a/docs/content/getting-started/index.md b/docs/content/getting-started/index.md new file mode 100644 index 00000000..e8b7dcda --- /dev/null +++ b/docs/content/getting-started/index.md @@ -0,0 +1,118 @@ +# ๐Ÿš€ Getting Started + +Welcome to **UltimateAuth** โ€” the modern authentication framework for .NET. + +UltimateAuth is designed to make authentication **simple to use**, while still being **powerful, flexible, and deeply secure** enough for real-world applications. + +## What is UltimateAuth? + +UltimateAuth is a **flow-driven authentication framework** that reimagines how authentication works in modern .NET applications. + +It unifies: + +- Session-based authentication +- Token-based authentication (JWT) +- PKCE flows for public clients +- Multi-client environments (Blazor Server, WASM, MAUI, APIs) + +into a single, consistent system. + +Instead of choosing between cookies, sessions, or tokens, UltimateAuth allows you to use **the right model for each scenario โ€” automatically**. + +## What Makes UltimateAuth Different? + +### ๐Ÿ”น Session is a First-Class Concept + +Unlike traditional systems, UltimateAuth treats sessions as a **core domain**, not a side effect. + +- Root โ†’ global security authority +- Chain โ†’ device context +- Session โ†’ actual authentication instance + +This allows: + +- Instant revocation +- Multi-device control +- Secure session lifecycle management + +### ๐Ÿ”น Flow-Based, Not Token-Based + +UltimateAuth is not cookie-based or token-based. + +It is **flow-based**: + +- Login is a flow +- Refresh is a flow +- Re-authentication is a flow + +๐Ÿ‘‰ Authentication becomes **explicit, predictable, and controllable** + +### ๐Ÿ”น Built for Blazor and Modern Clients + +UltimateAuth is designed from the ground up for: + +- Blazor Server +- Blazor WASM +- .NET MAUI + +With: + +- Native PKCE support +- Built-in Blazor components (`UAuthLoginForm`, `UAuthApp`) +- Automatic client profile detection + +๐Ÿ‘‰ No hacks. No manual glue code. + +### ๐Ÿ”น Runtime-Aware Authentication + +Authentication behavior is not static. + +UltimateAuth adapts based on: + +- Client type +- Auth mode +- Request context + +๐Ÿ‘‰ Same system, different optimized behavior. + +## What Problems It Solves + +UltimateAuth addresses real-world challenges: + +### ๐Ÿ”น Multiple Client Types + +Blazor Server, WASM, MAUI, and APIs all behave differently. + +UltimateAuth handles these differences automatically using **Client Profiles**. + +### ๐Ÿ”น Session vs Token Confusion + +Should you use cookies, sessions, or JWT? + +UltimateAuth removes this decision by supporting multiple auth modes and selecting the correct behavior at runtime. + +### ๐Ÿ”น Secure Session Management +- Device-aware sessions +- Session revocation +- Refresh token rotation +- Replay protection + +All built-in โ€” no custom implementation required. + +### ๐Ÿ”น Complex Auth Flows +Login, logout, refresh, PKCE, multi-device control etc. + +All exposed as **simple application-level APIs**. + +## When to Use UltimateAuth +Use UltimateAuth if: + +- You are building a modern .NET application **Blazor Server, WASM or MAUI** +- You need **session + token hybrid authentication** +- You want **full control over authentication flows** +- You are building a **multi-client system (web + mobile + API)** +- You need **strong security with extensibility** + + +๐Ÿ‘‰ Continue to **Quick Start** to build your first UltimateAuth application. + diff --git a/docs/content/getting-started/quickstart.md b/docs/content/getting-started/quickstart.md new file mode 100644 index 00000000..f6b5c27d --- /dev/null +++ b/docs/content/getting-started/quickstart.md @@ -0,0 +1,100 @@ +# โšก Quick Start + +In this guide, you will set up UltimateAuth in a few minutes and perform your **first login**. + +--- + +## 1. Create a Project + +Create a new Blazor Server web app: + +```bash +dotnet new blazorserver -n UltimateAuthDemo +cd UltimateAuthDemo +``` + +## 2. Install Packages + +Add UltimateAuth packages: + +```csharp +dotnet add package CodeBeam.UltimateAuth.Server +dotnet add package CodeBeam.UltimateAuth.Client.Blazor +dotnet add package CodeBeam.UltimateAuth.InMemory.Bundle +``` + +## 3. Configure Services + +Update your Program.cs: +```csharp +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthInMemory(); + +builder.Services + .AddUltimateAuthClientBlazor(); +``` + +## 4. Configure Middleware +In `Program.cs` +```csharp +app.UseUltimateAuthWithAspNetCore(); +app.MapUltimateAuthEndpoints(); +``` + +## 5. Enable Blazor Integration +In `Program.cs` +```csharp +app.MapRazorComponents() + .AddInteractiveServerRenderMode() // or webassembly (depends on your application type) + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); +``` + +## 6. Add UAuth Script +Add this to `App.razor` or `index.html`: + +```csharp + +``` + +## 7. Configure Application Lifecycle +Replace `Routes.razor` with this code: +```csharp + +``` + +## 8. Perform Your First Login +Example using IUAuthClient: +```csharp +[Inject] IUAuthClient UAuthClient { get; set; } = null!; + +private async Task Login() +{ + await UAuthClient.Flows.LoginAsync(new LoginRequest + { + Identifier = "demo", + Secret = "password" + }); +} +``` + +## ๐ŸŽ‰ Thatโ€™s It +You now have a working authentication system with: + +- Session-based authentication +- Automatic client detection +- Built-in login flow +- Secure session handling + +## What Just Happened? + +When you logged in: + +- A session (with root and chain) was created on the server, +- Your client received an authentication grant (cookie or token), +- UltimateAuth established your auth state automatically. + +๐Ÿ‘‰ You didnโ€™t manage cookies, tokens, or redirects manually. + +## Next Steps +Discover the setup for real world applications with entity framework core. diff --git a/docs/content/getting-started/real-world-setup.md b/docs/content/getting-started/real-world-setup.md new file mode 100644 index 00000000..48578b07 --- /dev/null +++ b/docs/content/getting-started/real-world-setup.md @@ -0,0 +1,126 @@ +# ๐Ÿ— Real-World Setup + +The Quick Start uses an in-memory setup for simplicity. +In real-world applications, you should replace it with a persistent configuration as shown below. + +In real applications, you will typically configure: + +- A persistent database +- An appropriate client profile +- A suitable authentication mode + +This guide shows how to set up UltimateAuth for real-world scenarios. + +## ๐Ÿ—„๏ธ Using Entity Framework Core + +For production, you should use a persistent store. In this setup, you no longer need the `CodeBeam.UltimateAuth.InMemory.Bundle` package. + +### Install Packages + +```bash +dotnet add package CodeBeam.UltimateAuth.EntityFrameworkCore.Bundle +``` + +### Configure Services +```csharp +builder.Services + .AddUltimateAuthServer() + .AddUltimateAuthEntityFrameworkCore(db => + { + db.UseSqlite("Data Source=uauth.db"); + // or UseSqlServer / UseNpgsql + }); + +builder.Services + .AddUltimateAuthClientBlazor(); +``` + +### Create Database & Migrations +```bash +dotnet ef migrations add InitUAuth +dotnet ef database update +``` +or + +If you are using Visual Studio, you can run these commands in Package Manager Console*: +```bash +Add-Migration InitUAuth -Context UAuthDbContext +Update-Database -Context UAuthDbContext +``` +*Needs `Microsoft.EntityFrameworkCore.Design` and `Microsoft.EntityFrameworkCore.Tools` + +## Configure Services With Options +UltimateAuth provides rich options for server and client service registration. +```csharp +builder.Services.AddUltimateAuthServer(o => { + o.Diagnostics.EnableRefreshDetails = true; + o.Login.MaxFailedAttempts = 4; + o.Identifiers.AllowMultipleUsernames = true; +}); +``` + +## Blazor WASM Setup +Blazor WASM applications run entirely on the client and cannot securely handle credentials. +For this reason, UltimateAuth uses a dedicated Auth server called **UAuthHub**. + +WASM `Program.cs`: +```csharp +builder.Services.AddUltimateAuthClientBlazor(o => +{ + o.Endpoints.BasePath = "https://localhost:6110/auth"; // UAuthHub URL + o.Pkce.ReturnUrl = "https://localhost:6130/home"; // Your (WASM) application domain + return path +}); +``` + +UAuthHub `Program.cs`: +```csharp +builder.Services.AddUltimateAuthServer() + .AddUltimateAuthInMemory() + .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // WASM application's URL +``` + +UAuthHub Pipeline Configuration +```csharp +app.MapUltimateAuthEndpoints(); +app.MapUAuthHub(); +``` + +> โ„น๏ธ UltimateAuth automatically selects the appropriate authentication mode (PureOpaque, Hybrid, etc.) based on the client type. + +## ResourceApi Setup +You may want to secure your custom API with UltimateAuth. UltimateAuth provides a lightweight option for this case. (ResourceApi doesn't have to be a blazor application, it can be any server-side project like MVC.) + +ResourceApi's `Program.cs` +```csharp +builder.Services.AddUltimateAuthResourceApi(o => + { + o.UAuthHubBaseUrl = "https://localhost:6110"; + o.AllowedClientOrigins.Add("https://localhost:6130"); + }); +``` + +Configure pipeline: +```csharp +app.UseUltimateAuthResourceApiWithAspNetCore(); +``` + +Notes: +- ResourceApi should connect with an UAuthHub, not a pure-server. Make sure `.AddUAuthHub()` after calling `builder.Services.AddUltimateAuthServer()`. +- UltimateAuth automatically configures CORS based on the provided origins. + +Use ResourceApi when: + +- You have a separate backend API +- You want to validate sessions or tokens externally +- Your API is not hosting UltimateAuth directly + +## ๐Ÿง  How to Think About Setup + +In UltimateAuth: + +- The **Server** manages authentication flows and sessions +- The **Client** interacts through flows (not tokens directly) +- The **Storage layer** (InMemory / EF Core) defines persistence +- The **Application type** determines runtime behavior + +๐Ÿ‘‰ You configure the system once, and UltimateAuth adapts automatically. diff --git a/docs/content/plugin-domains/authorization-domain.md b/docs/content/plugin-domains/authorization-domain.md new file mode 100644 index 00000000..735ab0e8 --- /dev/null +++ b/docs/content/plugin-domains/authorization-domain.md @@ -0,0 +1,181 @@ +# ๐Ÿ” Authorization & Policies + +UltimateAuth provides a flexible and extensible authorization system based on: + +- Roles +- Permissions +- Policies +- Access orchestration + +## ๐Ÿงฉ Core Concepts + +### ๐Ÿ”‘ Permissions + +In UltimateAuth, permissions are not just arbitrary strings. + +They follow a **structured action model**. + +#### ๐Ÿงฉ Permission Structure + +Permissions are built using a consistent format: + +``` +resource.operation.scope +``` +or +``` +resource.subresource.operation.scope +``` + +#### โœ… Examples + +- `users.create.admin` +- `users.profile.update.self` +- `sessions.revokechain.admin` +- `credentials.change.self` + +๐Ÿ‘‰ This structure is not accidental โ€” +it is **designed for consistency, readability, and policy evaluation**. + +--- + +### โš™๏ธ Built-in Action Catalog + +UltimateAuth provides a predefined action catalog. + +Examples: + +- `flows.logout.self` +- `sessions.listchains.admin` +- `users.delete.self` +- `credentials.revoke.admin` +- `authorization.roles.assign.admin` + +๐Ÿ‘‰ This ensures: + +- No magic strings +- Discoverable permissions +- Consistent naming across the system + +
+ +### ๐Ÿง  Scope Semantics + +The last part of the permission defines **scope**: + +| Scope | Meaning | +|------------|----------------------------------| +| `self` | User acts on own resources | +| `admin` | User acts on other users | +| `anonymous`| No authentication required | + +
+ +### ๐ŸŒฒ Wildcards & Grouping + +Permissions support hierarchical matching: + +- `users.*` โ†’ all user actions +- `users.profile.*` โ†’ all profile operations +- `*` โ†’ full access + +### โšก Normalization + +Permissions are automatically normalized: + +- Full coverage โ†’ replaced with `*` +- Full group โ†’ replaced with `prefix.*` + +
+ +### Role + +A role is a collection of permissions. + +- Roles are tenant-scoped +- Roles can be dynamically updated +- Permissions are normalized internally + +### UserRole + +Users are assigned roles: + +- Many-to-many relationship +- Assignment is timestamped +- Role resolution is runtime-based + +
+ +## ๐Ÿ”„ Permission Resolution + +Permissions are evaluated using: + +- Exact match +- Prefix match +- Wildcard match + +CompiledPermissionSet optimizes runtime checks. + +
+ +## ๐Ÿง  Claims Integration + +Authorization integrates with authentication via claims: + +- Roles โ†’ `ClaimTypes.Role` +- Permissions โ†’ `permission` claim +- Tenant โ†’ `tenant` + +This allows: + +- Token-based authorization +- Stateless permission checks (for JWT modes) + +
+ +## โš™๏ธ Authorization Flow + +Authorization is executed through: + +๐Ÿ‘‰ AccessOrchestrator + +Steps: + +1. Build AccessContext +2. Execute policies +3. Allow or deny operation + +
+ +## ๐Ÿ›ก Policies + +Policies are the core of authorization logic. + +Default policies include: + +- RequireAuthenticated +- DenyCrossTenant +- RequireActiveUser +- RequireSelf +- RequireSystem +- MustHavePermission + +
+ +## ๐Ÿ”Œ Plugin Integration + +Authorization is a plugin domain. + +It: + +- Does NOT depend on other domains +- Uses contracts only +- Integrates via policies and claims + +## ๐ŸŽฏ Key Takeaways + +- Authorization is policy-driven +- Roles are permission containers +- Permissions support wildcard & prefix +- Policies enforce rules +- Fully extensible and replaceable diff --git a/docs/content/plugin-domains/credential-domain.md b/docs/content/plugin-domains/credential-domain.md new file mode 100644 index 00000000..50a8c2e3 --- /dev/null +++ b/docs/content/plugin-domains/credential-domain.md @@ -0,0 +1,115 @@ +# ๐Ÿ” Credentials Domain + +Credentials in UltimateAuth define how a user proves their identity. + +## ๐Ÿง  Core Concept + +Authentication is not tied to users directly. + +๐Ÿ‘‰ It is performed through credentials. + +
+ +## ๐Ÿ”‘ What is a Credential? + +A credential represents a secret or factor used for authentication. + +Examples: + +- Password +- OTP (future) +- External providers (future) + +
+ +## ๐Ÿ”’ Password Credential + +The default credential type is password. + +A password credential contains: + +- Hashed secret (never raw password) +- Security state (active, revoked, expired) +- Metadata (last used, source, etc.) + +๐Ÿ‘‰ Credentials are always stored securely and validated through hashing. + +
+ +## โš™๏ธ Credential Validation + +Credential validation is handled by a validator: + +- Verifies secret using hashing +- Checks credential usability (revoked, expired, etc.) + +๐Ÿ‘‰ Validation is isolated from business logic. + +
+ +## ๐Ÿ”— Integration with Users + +Credentials are NOT created directly inside user logic. + +Instead: + +๐Ÿ‘‰ They are integrated via lifecycle hooks + +Example: + +- When a user is created โ†’ password credential may be created +- When a user is deleted โ†’ credentials are removed + +๐Ÿ‘‰ This keeps domains decoupled. + +
+ +## ๐Ÿ”„ Credential Lifecycle + +Credentials support: + +- Creation +- Secret change +- Revocation +- Expiration +- Deletion + +
+ +## ๐Ÿ” Security Behavior + +Credential changes trigger security actions: + +- Changing password revokes sessions +- Reset flows require verification tokens +- Invalid attempts are tracked + +๐Ÿ‘‰ Credentials are tightly coupled with security. + +
+ +## ๐Ÿ”‘ Reset Flow + +Password reset is a multi-step process: + +1. Begin reset (generate token or code) +2. Validate token +3. Apply new secret + +๐Ÿ‘‰ Reset flow is protected against enumeration and abuse. + +
+ +## ๐Ÿง  Mental Model + +Users define identity. + +Credentials define authentication. + +## ๐ŸŽฏ Summary + +- Credentials handle authentication secrets +- Password is default but extensible +- Integrated via lifecycle hooks +- Strong security guarantees +- Fully extensible for new credential types diff --git a/docs/content/plugin-domains/index.md b/docs/content/plugin-domains/index.md new file mode 100644 index 00000000..596d2e23 --- /dev/null +++ b/docs/content/plugin-domains/index.md @@ -0,0 +1,146 @@ +# ๐Ÿงฉ Plugin Domains + +Authentication alone is not enough. + +Real-world systems also require: + +- User management +- Credential handling +- Authorization rules + +๐Ÿ‘‰ UltimateAuth provides these as **plugin domains** + +## ๐Ÿง  What Is a Plugin Domain? + +A plugin domain is a **modular business layer** built on top of UltimateAuth. + +๐Ÿ‘‰ Core handles authentication +๐Ÿ‘‰ Plugin domains handle identity and access logic + +
+ +## ๐Ÿ— Architecture + +Each plugin domain is composed of multiple layers: + +### ๐Ÿ”น Bridge Package + +Defines the server needed bridge interfaces: + +๐Ÿ‘‰ This package provides required minimal info for server package. + +### ๐Ÿ”น Contracts Package + +Shared models between: + +- Server +- Client + +๐Ÿ‘‰ Includes DTOs, requests, responses + +### ๐Ÿ”น Reference Implementation + +Provides default behavior: + +- Application services +- Store interfaces +- Default implementations + +๐Ÿ‘‰ Acts as a production-ready baseline + +### ๐Ÿ”น Persistence Layer + +Provides storage implementations: + +- InMemory +- Entity Framework Core + +๐Ÿ‘‰ Additional providers (Redis, etc.) can be added + +
+ +## ๐Ÿ”„ Extensibility Model + +Plugin domains are designed to be: + +- Replaceable +- Extendable +- Composable + +๐Ÿ‘‰ You can implement your own persistence +๐Ÿ‘‰ You can extend behavior + +
+ +## โš ๏ธ Recommended Approach + +In most cases: + +๐Ÿ‘‰ You should NOT replace a plugin domain entirely + +Instead: + +- Use provided implementations +- Extend via interfaces +- Customize behavior where needed + +๐Ÿ‘‰ This ensures compatibility with the framework + +
+ +## ๐Ÿ”Œ Domain Isolation + +Plugin domains are designed to be **fully isolated** from each other. + +๐Ÿ‘‰ A plugin domain does NOT reference another plugin domain +๐Ÿ‘‰ There are no direct dependencies between domains + +### ๐Ÿง  Why? + +This ensures: + +- Loose coupling +- Independent evolution +- Replaceability +- Clear boundaries + +## ๐Ÿ”„ Communication via Hooks + +Plugin domains communicate **only through integration hooks**. + +For example: + +- User domain triggers โ†’ `OnUserCreated` +- Credential domain listens โ†’ creates password credential + +๐Ÿ‘‰ This is implemented via abstractions such as: + +- `IUserLifecycleIntegration` +- domain events / integration points + +
+ +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Core = authentication engine +๐Ÿ‘‰ Plugin domains = business logic + +## ๐ŸŽฏ Why This Matters + +This architecture allows UltimateAuth to: + +- Stay modular +- Support multiple domains +- Enable enterprise customization +- Avoid monolithic identity systems + +๐Ÿ‘‰ You donโ€™t build everything from scratch +๐Ÿ‘‰ You assemble what you need + +## โžก๏ธ Next Step + +- Manage users โ†’ Users +- Handle credentials โ†’ Credentials +- Control access โ†’ Authorization diff --git a/docs/content/plugin-domains/policies.md b/docs/content/plugin-domains/policies.md new file mode 100644 index 00000000..111f0bbe --- /dev/null +++ b/docs/content/plugin-domains/policies.md @@ -0,0 +1,127 @@ +# ๐Ÿ›ก Policies & Access Control + +UltimateAuth uses a **policy-driven authorization model**. + +Policies are not simple checks โ€” +they are **composable decision units** evaluated at runtime. + +## ๐Ÿง  Mental Model + +Authorization in UltimateAuth is: + +๐Ÿ‘‰ Context-based +๐Ÿ‘‰ Policy-driven +๐Ÿ‘‰ Orchestrated + +### Flow + +1. Build `AccessContext` +2. Resolve policies +3. Execute authority +4. Allow / Deny / Reauth + +
+ +## โš™๏ธ AccessContext + +Every authorization decision is based on: + +- Actor (who is calling) +- Target (what is being accessed) +- Action (what is being done) +- Tenant +- Claims / permissions + +
+ +## ๐Ÿ”Œ Policy Resolution + +Policies are resolved using: + +- Action prefix matching +- Runtime filtering (`AppliesTo`) + +Example: + +- `users.create.admin` +- `users.*` +- `authorization.roles.*` + +
+ +## ๐Ÿงฉ Policy Types + +### Global Policies + +Always evaluated: + +- RequireAuthenticated +- DenyCrossTenant + +### Runtime Policies + +Resolved dynamically: + +- RequireActiveUser +- MustHavePermission +- RequireSelf + +### Invariants + +Executed first: + +- Cannot be bypassed +- Hard security rules + +
+ +## โš–๏ธ Policy Evaluation + +Evaluation order: + +1. Invariants +2. Global policies +3. Runtime policies + +๐Ÿ‘‰ First deny wins +๐Ÿ‘‰ Allow means โ€œno objectionโ€ +๐Ÿ‘‰ Reauth can be requested + +
+ +## ๐Ÿ” Example Policy + +### Deny Admin Self Modification + +- Blocks admin modifying own account +- Applies only to `.admin` actions +- Ignores read operations + +### Require Active User + +- Ensures user exists +- Ensures user is active +- Skips anonymous actions + +
+ +## ๐Ÿš€ Access Orchestrator + +The orchestrator is the entry point: + +- Enriches context (claims, permissions) +- Resolves policies +- Executes authority +- Runs command if allowed + +## ๐ŸŽฏ Key Principles + +- Policies are composable +- Authorization is deterministic +- No hidden magic +- Fully extensible + +--- + +๐Ÿ‘‰ Authorization is not a single check +๐Ÿ‘‰ It is a **pipeline of decisions** diff --git a/docs/content/plugin-domains/users-domain.md b/docs/content/plugin-domains/users-domain.md new file mode 100644 index 00000000..45630abb --- /dev/null +++ b/docs/content/plugin-domains/users-domain.md @@ -0,0 +1,114 @@ +# ๐Ÿ‘ค Users Domain + +Users in UltimateAuth are not a single entity. + +Instead, they are composed of multiple parts that together define identity. + +## ๐Ÿง  Core Concept + +A user is represented by three main components: + +- Lifecycle (security & state) +- Identifiers (login surface) +- Profile (user data) + +
+ +## ๐Ÿ” Lifecycle (Security Anchor) + +Lifecycle defines: + +- When a user created +- Whether a user is active, suspended, or deleted +- Security version (used for invalidating sessions/tokens) + +๐Ÿ‘‰ This is the core of authentication security. + +
+ +## ๐Ÿ”‘ Identifiers (Login System) + +Identifiers represent how a user logs in: + +- Email +- Username +- Phone + +Each identifier has: + +- Normalized value +- Primary flag +- Verification state + +### โญ Login Identifiers + +Not all identifiers are used for login. + +๐Ÿ‘‰ Only **primary identifiers** are considered login identifiers. + +#### โš™๏ธ Configurable Behavior + +Developers can control: + +- Which identifier types are allowed for login +- Whether email/phone must be verified +- Whether multiple identifiers are allowed +- Global uniqueness rules + +#### ๐Ÿ”Œ Custom Login Logic + +UltimateAuth allows custom login identifier resolution. + +You can: + +- Add custom identifier types +- Override resolution logic +- Implement your own resolver + +๐Ÿ‘‰ This means login is fully extensible. + +
+ +## ๐Ÿงพ Profile (User Data) + +Profile contains non-auth data: + +- Name +- Bio +- Localization +- Metadata + +๐Ÿ‘‰ This is not used for authentication. + +
+ +## โš™๏ธ Application Service + +User operations are handled by an orchestration layer. + +It is responsible for: + +- Creating users +- Managing identifiers +- Applying validation rules +- Triggering integrations +- Revoking sessions when needed + +
+ +## ๐Ÿ” Mental Model + +Authentication answers: + +โ†’ Who are you? + +Users domain answers: + +โ†’ What is this user? + +## ๐ŸŽฏ Summary + +- Users are composed, not singular +- Login is based on identifiers +- Login identifiers are configurable +- System is extensible by design diff --git a/docs/content/readme.md b/docs/content/readme.md new file mode 100644 index 00000000..20daca39 --- /dev/null +++ b/docs/content/readme.md @@ -0,0 +1,67 @@ +# ๐Ÿ” UltimateAuth Docs + +The modern, unified auth framework with platform-level abilities for .NET - Reimagined. + +## Sections + +UltimateAuth docs consists of 7 sections. There are suggested read line for starters: + +### 1) Getting Started + +Start here to integrate UltimateAuth into your application: + +- Getting Started Guide +- Setup with different client profiles +- Basic usage + +### 2) Fundamentals + +Understand how UltimateAuth works internally: + +- Core Concepts +- Session Architecture (Root โ†’ Chain โ†’ Session) +- Token Model +- Authorization Model + +### 3) Auth Flows + +Explore authentication lifecycle: + +- Login Flow +- Refresh Flow +- Logout Flow +- Session Lifecycle +- Token Behavior +- Device Management + +### 4) Plugin Domains + +UltimateAuth is built as modular domains: + +- Users +- Credentials +- Authorization + +### 5) Configuration & Extensibility + +Customize behavior and integrate with your system: + +- Server Options +- Client Options +- Configuration Sources +- Advanced Configuration +- Extending Domains & Stores + +### 6) Client & API + +- Real world usage of UltimateAuth +- API and code examples of authentication, sessions, users, credentials and authorization + +### 7) Security & Advanced + +Deep dive into the security model: + +- Session Security Model +- Refresh Token Rotation +- Access Token Behavior +- Policy Pipeline diff --git a/docs/content/security/access-token-behavior.md b/docs/content/security/access-token-behavior.md new file mode 100644 index 00000000..4313693e --- /dev/null +++ b/docs/content/security/access-token-behavior.md @@ -0,0 +1,165 @@ +# ๐ŸŽฏ Access Token Behavior + +Access tokens in UltimateAuth are intentionally **short-lived and mode-aware**. +They are not the primary source of truth for authentication. + +## ๐Ÿง  Core Principle + +In UltimateAuth: + +๐Ÿ‘‰ Access tokens are **transport tokens** +๐Ÿ‘‰ Sessions are the **source of truth** + +## ๐Ÿ”‘ Token Types + +UltimateAuth supports two access token types: + +### ๐Ÿ”น Opaque Tokens + +- Random, non-readable values +- Stored and validated server-side +- Typically used with session-based auth + +### ๐Ÿ”น JWT Tokens + +- Self-contained tokens +- Contain claims (user, tenant, session, etc.) +- Signed and verifiable without storage + +## โš™๏ธ Mode-Dependent Behavior + +Access token behavior depends on auth mode: + +### ๐ŸŸข PureOpaque + +- No JWT issued +- Session cookie is the primary mechanism +- Access token may not exist externally + +๐Ÿ‘‰ Validation = session validation + +### ๐ŸŸก Hybrid + +- Opaque + JWT together +- Session still authoritative +- JWT used for API access + +๐Ÿ‘‰ Validation = session + token + +### ๐ŸŸ  SemiHybrid + +- JWT is primary access token +- Session still exists +- Refresh rotation enabled + +๐Ÿ‘‰ Balanced approach + +### ๐Ÿ”ต PureJwt + +- Only JWT + refresh tokens +- No session state required + +๐Ÿ‘‰ Stateless mode + +## โฑ Lifetime Strategy + +Access tokens are: + +- short-lived +- replaceable +- not trusted long-term + +Typical lifetime: + +```text +5โ€“15 minutes +``` + +## ๐Ÿ”„ Refresh Interaction + +Access tokens are never extended directly. + +Instead: + +```text +Access Token โ†’ expires โ†’ Refresh โ†’ new Access Token +``` + +๐Ÿ‘‰ This ensures: + +- forward-only security +- no silent extension +- replay window minimization + +## ๐Ÿงพ Claims Model + +JWT tokens may include: + +- sub (user id) +- tenant +- sid (session id) +- jti (token id) +- custom claims + +๐Ÿ‘‰ Claims are generated at issuance time +๐Ÿ‘‰ Not dynamically updated afterward + +## โš ๏ธ Important Implication + +If something changes: + +- user roles +- permissions +- security state + +๐Ÿ‘‰ existing JWTs are NOT updated + +๐Ÿ‘‰ This is why: + +- tokens are short-lived +- refresh is required +- session validation may still apply + +## ๐Ÿ›ก Security Boundaries + +Access tokens are: + +โŒ not revocable individually (JWT) +โŒ not long-term identity + +โœ” tied to session or refresh flow +โœ” bounded by expiration + +## ๐Ÿ”ฅ Why This Matters + +UltimateAuth avoids a common mistake: + +๐Ÿ‘‰ treating JWT as the system of record + +Instead: + +๐Ÿ‘‰ JWT is a **snapshot**, not truth + +## โš ๏ธ Design Tradeoff + +Short-lived tokens mean: + +- more refresh calls +- slightly more backend interaction + +๐Ÿ‘‰ But this enables: + +- safer rotation +- better revocation +- reduced attack window + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Access tokens are **temporary access grants** +๐Ÿ‘‰ Not persistent identity + +## โžก๏ธ Next Step + +Continue to **Policy Pipeline Deep Dive** to understand how access decisions are enforced. diff --git a/docs/content/security/policy-pipeline.md b/docs/content/security/policy-pipeline.md new file mode 100644 index 00000000..1ff9fc78 --- /dev/null +++ b/docs/content/security/policy-pipeline.md @@ -0,0 +1,223 @@ +# ๐Ÿง  Policy Pipeline Deep Dive + +UltimateAuth does not rely on simple role checks. + +Authorization is executed through a **multi-stage policy pipeline** that evaluates: + +- invariants (always enforced rules) +- global policies +- runtime (action-based) policies + +## ๐Ÿง  Core Principle + +๐Ÿ‘‰ Authorization is not a single check +๐Ÿ‘‰ It is a **decision pipeline** + +## ๐Ÿ” High-Level Flow + +```text +AccessContext + โ†“ +Enrichment (claims, permissions) + โ†“ +Invariants + โ†“ +Global Policies + โ†“ +Runtime Policies (action-based) + โ†“ +Final Decision +``` + +## ๐Ÿงฉ AccessContext + +Every authorization decision is based on an `AccessContext`. + +It contains: + +- actor (who is making the request) +- target (resource / user) +- tenant +- action (string-based, e.g. "users.delete") +- attributes (claims, permissions, etc.) + +๐Ÿ‘‰ Everything in the pipeline reads from this context + +## ๐Ÿ”„ Step 1: Context Enrichment + +Before policies run, context is enriched: + +- permissions are loaded +- compiled into `CompiledPermissionSet` +- attached to context + +๐Ÿ‘‰ This allows policies to run without hitting storage repeatedly + +## ๐Ÿ›ก Step 2: Invariants + +Invariants are **always enforced rules**. + +They run first and cannot be bypassed. + +Examples: + +- user must be authenticated +- cross-tenant access is denied +- request must be valid + +๐Ÿ‘‰ If an invariant fails: + +```text +โ†’ DENY immediately +``` + +## ๐ŸŒ Step 3: Global Policies + +Global policies apply to all requests but are conditional. + +Examples: + +- RequireActiveUserPolicy +- DenyAdminSelfModificationPolicy + +Each policy: + +- decides if it applies (`AppliesTo`) +- evaluates (`Decide`) + +๐Ÿ‘‰ Important: + +- โ€œAllowโ€ means *no objection* +- not final approval + +## ๐ŸŽฏ Step 4: Runtime Policies + +Runtime policies are: + +- action-based +- dynamically resolved + +They come from: + +```text +AccessPolicyRegistry โ†’ CompiledAccessPolicySet +``` + +Policies are selected by: + +```text +context.Action.StartsWith(prefix) +``` + +Example: + +```text +Action: users.delete.admin + +Matches: +- users.* +- users.delete.* +- users.delete.admin +``` + +## โš–๏ธ Step 5: Decision Engine + +All policies are evaluated by: + +๐Ÿ‘‰ `IAccessAuthority` + +Evaluation order: + +1. Invariants +2. Global policies +3. Runtime policies + +### Decision Rules + +- First **deny** โ†’ stops execution +- โ€œAllowโ€ โ†’ continue +- โ€œRequiresReauthenticationโ€ โ†’ tracked + +Final result: + +```text +Allow +Deny(reason) +ReauthenticationRequired +``` + +## ๐Ÿ” Permission Integration + +Permissions are not directly checked in services. + +Instead: + +- permissions are compiled +- policies use them + +Example policy: + +```text +MustHavePermissionPolicy +``` + +Checks: + +```text +CompiledPermissionSet.IsAllowed(action) +``` + + +๐Ÿ‘‰ This decouples: + +- permission storage +- authorization logic + +## ๐Ÿ”ฅ Why This Matters + +This pipeline enables: + +- composable security rules +- consistent enforcement +- separation of concerns +- extensibility + +Compared to typical systems: + +| Feature | Traditional | UltimateAuth | +|---------------------|------------|-------------| +| Inline checks | โœ… | โŒ | +| Central pipeline | โŒ | โœ… | +| Policy composition | โŒ | โœ… | +| Action-based rules | โŒ | โœ… | + +## โš ๏ธ Design Tradeoff + +This model introduces: + +- more abstraction +- more components + +๐Ÿ‘‰ But gives: + +- predictability +- auditability +- flexibility + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Authorization is not โ€œif (role == admin)โ€ +๐Ÿ‘‰ It is a **pipeline of decisions** + +## โžก๏ธ Summary + +UltimateAuth authorization: + +- is policy-driven +- runs through a structured pipeline +- separates invariants, global rules, and action rules +- produces deterministic decisions + +๐Ÿ‘‰ This makes it suitable for complex, multi-tenant, security-sensitive systems diff --git a/docs/content/security/refresh-rotation.md b/docs/content/security/refresh-rotation.md new file mode 100644 index 00000000..2db9b053 --- /dev/null +++ b/docs/content/security/refresh-rotation.md @@ -0,0 +1,163 @@ +# ๐Ÿ”„ Refresh Token Rotation + +Refresh tokens in UltimateAuth are not simple long-lived tokens. + +They are part of a **rotation-based security system** designed to: + +- prevent token replay +- detect token theft +- enforce forward-only session progression + +## ๐Ÿง  Why Rotation? + +In traditional systems: + +- refresh tokens are long-lived +- they can be reused multiple times + +๐Ÿ‘‰ If stolen, an attacker can: + +- silently keep refreshing access tokens +- stay undetected + +UltimateAuth solves this with: + +๐Ÿ‘‰ **single-use refresh tokens (rotation)** + +## ๐Ÿ” Rotation Model + +Each refresh token is: + +- used exactly once +- replaced with a new token +- linked to a chain + +```text +Token A โ†’ Token B โ†’ Token C โ†’ ... +``` + +When a refresh happens: + +1. Token A is validated +2. Token A is **revoked** +3. Token B is issued +4. Token A is marked as replaced by B + +## ๐Ÿ” Token State + +A refresh token can be: + +- Active โ†’ valid and usable +- Revoked โ†’ already used or manually revoked +- Expired โ†’ lifetime exceeded +- Replaced โ†’ already rotated + +๐Ÿ‘‰ Only **active tokens** are valid + +## ๐Ÿšจ Reuse Detection + +This is the most critical security feature. + +If a refresh token is used **after it has already been rotated**, it means: + +๐Ÿ‘‰ The token was reused +๐Ÿ‘‰ Likely stolen + +### What happens? + +When reuse is detected: + +- the system identifies the session chain +- the entire chain can be revoked +- all related sessions become invalid + +๐Ÿ‘‰ This immediately cuts off both: + +- attacker +- legitimate user (forcing reauthentication) + +## ๐Ÿ”— Chain Awareness + +Refresh tokens belong to a **session chain**. + +This enables: + +- tracking rotation history +- detecting anomalies +- applying revocation at the correct scope + +Without chains: + +โŒ You cannot safely detect reuse +โŒ You cannot know which tokens belong together + +## ๐Ÿ”„ Rotation Flow + +```text +Client โ†’ Refresh(Token A) + โ†’ Validate A + โ†’ Revoke A + โ†’ Issue B + โ†’ Return new tokens +``` + +## โš ๏ธ Invalid Scenarios + +### 1. Expired Token + +```text +Token expired โ†’ reject +``` + +### 2. Revoked Token + +```text +Token already used โ†’ reuse detected +``` + +### 3. Session Mismatch + +```text +Token does not belong to expected session โ†’ reject +``` + +## ๐Ÿง  Security Guarantees + +Rotation ensures: + +- refresh tokens are forward-only +- old tokens cannot be reused safely +- stolen tokens are detectable +- compromise triggers containment + +## ๐Ÿ”ฅ Why This Matters + +Compared to traditional refresh tokens: + +| Feature | Traditional | UltimateAuth | +|----------------------|------------|-------------| +| Reusable tokens | โœ… | โŒ | +| Reuse detection | โŒ | โœ… | +| Chain awareness | โŒ | โœ… | +| Automatic containment| โŒ | โœ… | + +## โš ๏ธ Design Tradeoff + +Rotation requires: + +- token storage +- state tracking +- additional validation logic + +๐Ÿ‘‰ UltimateAuth chooses security over simplicity. + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ A refresh token is not a reusable key +๐Ÿ‘‰ It is a **one-time step in a chain** + +## โžก๏ธ Next Step + +Continue to **Access Token Behavior** to understand how short-lived tokens interact with rotation. diff --git a/docs/content/security/session-security-model.md b/docs/content/security/session-security-model.md new file mode 100644 index 00000000..f61c00fd --- /dev/null +++ b/docs/content/security/session-security-model.md @@ -0,0 +1,214 @@ +# ๐Ÿ” Session Security Model + +UltimateAuth is built around a **hierarchical session model**. + +This is the foundation of its security design. + +## ๐Ÿง  Why a Session Model? + +Many systems treat authentication as one of these: + +- a cookie +- a bearer token +- a login flag + +That works for simple apps. + +It breaks down when you need: + +- per-device isolation +- targeted revocation +- security state propagation +- reliable reauthentication boundaries + +UltimateAuth solves this by modeling authentication as: + +```text +Root โ†’ Chain โ†’ Session +``` + +
+ +## ๐Ÿงฉ The Three Layers + +### ๐Ÿ”น Root + +A **Root** represents the user-level authentication authority. + +It is: + +- tenant-bound +- user-bound +- long-lived +- security-versioned + +The root is the highest-level trust anchor for authentication state. + +### ๐Ÿ“ฑ Chain + +A **Chain** represents a device-level authentication boundary. + +It groups sessions that belong to the same device or client context. + +A chain is where UltimateAuth models: + +- device continuity +- touch activity +- rotation tracking +- device-level revoke + +### ๐Ÿ”‘ Session + +A **Session** is a single authentication instance. + +It is the most granular identity proof in the system. + +A session contains: + +- creation time +- expiration time +- revocation state +- security version snapshot +- chain relationship +- device snapshot +- claims snapshot + + +## ๐Ÿ”— Relationship Model + +```text +User + โ””โ”€โ”€ Root + โ””โ”€โ”€ Chain (device) + โ””โ”€โ”€ Session (login instance) +``` + +๐Ÿ‘‰ Root answers: **what is the current security authority for this user?** +๐Ÿ‘‰ Chain answers: **which device context is this?** +๐Ÿ‘‰ Session answers: **which authentication instance is being used?** + +## ๐Ÿ›ก Security Versioning + +One of the most important protections in UltimateAuth is **security versioning**. + +A root maintains a security version. + +Each session stores the security version that existed at creation time. + +Validation compares them. + +If they no longer match: + +```text +Session โ†’ invalid +``` + +This is how UltimateAuth can invalidate existing sessions after events such as: + +- password change +- credential reset +- account recovery +- administrative security action + +## ๐Ÿ” Validation Model + +Session validation is not a single check. + +It is a layered verification process. + +A session is considered valid only if all of these still hold: + +- the session exists +- the session is active +- the chain exists +- the chain is active +- the chain belongs to the expected tenant +- the chain matches the session +- the root exists +- the root is not revoked +- the root matches the chain +- the root security version matches the session snapshot + +## ๐Ÿ“ฑ Device Awareness + +Chains provide device-level isolation. + +When a device identifier is available, validation can compare: + +- device stored on chain +- device presented by request + +If they do not match, UltimateAuth can reject the request depending on configuration. + +๐Ÿ‘‰ This makes device-bound security enforceable without turning every authentication flow into a custom implementation. + +## โฑ Expiration and Activity + +UltimateAuth separates: + +- session expiration +- chain activity +- root security state + +This is important. + +A session may expire because of time. +A chain may become inactive because of idle timeout. +A root may invalidate everything because security state changed. + +These are different failure modes with different meanings. + +## ๐Ÿšช Revocation Boundaries + +Revocation is also hierarchical. + +### Session revoke +Invalidates one authentication instance. + +### Chain revoke +Invalidates all sessions for one device. + +### Root revoke +Invalidates all chains and sessions for the user. + +๐Ÿ‘‰ This gives UltimateAuth targeted containment. + +Instead of โ€œlog out everywhere or nowhere,โ€ +you can revoke exactly the right scope. + +## ๐Ÿ”ฅ Why This Matters + +This model gives you controls that flat token systems usually do not provide: + +- revoke one device without affecting others +- invalidate sessions after security changes +- reason about device trust explicitly +- separate authentication lifetime from token lifetime + +## โš ๏ธ Design Tradeoff + +This model is more sophisticated than plain JWT-only authentication. + +That is intentional. + +UltimateAuth chooses: + +- explicit state +- revocability +- traceable security decisions + +over: + +- minimal infrastructure +- purely stateless assumptions + +## ๐Ÿง  Mental Model + +If you remember one thing: + +๐Ÿ‘‰ Authentication in UltimateAuth is not โ€œa tokenโ€ +๐Ÿ‘‰ It is a **security hierarchy with revocation boundaries** + +## โžก๏ธ Next Step + +Continue to **Refresh Token Rotation** to understand how continuation security works after login. diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor new file mode 100644 index 00000000..3fbd186f --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor @@ -0,0 +1,27 @@ +๏ปฟ@inherits ComponentBase + + + + + + + + + + diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor.cs b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor.cs new file mode 100644 index 00000000..f93d84fd --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogo.razor.cs @@ -0,0 +1,46 @@ +๏ปฟusing Microsoft.AspNetCore.Components; +using MudBlazor.Utilities; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Docs.Wasm.Client.Brand; + +public partial class UAuthLogo : ComponentBase +{ + [Parameter] public UAuthLogoVariant Variant { get; set; } = UAuthLogoVariant.Brand; + + [Parameter] public int Size { get; set; } = 32; + + [Parameter] public Color Color { get; set; } = Color.Primary; + + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } + + protected string KeyPath => @" +M120.43,39.44H79.57A11.67,11.67,0,0,0,67.9,51.11V77.37 +A11.67,11.67,0,0,0,79.57,89H90.51l3.89,3.9v5.32l-3.8,3.81v81.41H99 +v-5.33h13.69V169H108.1v-3.8H99C99,150.76,111.9,153,111.9,153 +V99.79h-8V93.32L108.19,89h12.24 +A11.67,11.67,0,0,0,132.1,77.37V51.11 +A11.67,11.67,0,0,0,120.43,39.44Z + +M79.57,48.19h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.84a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.84a2.91,2.91 0 0 1 2.91,-2.92Z + +M79.57,68.62h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.83a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.83a2.91,2.91 0 0 1 2.91,-2.92Z + +M114.59,48.19h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.84a2.91,2.91 0 0 1 -2.91,2.91 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.91 +v-5.84a2.92,2.92 0 0 1 2.92,-2.92Z + +M114.59,68.62h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.83a2.91,2.91 0 0 1 -2.91,2.92 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.92 +v-5.83a2.92,2.92 0 0 1 2.92,-2.92Z +"; +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogoVariant.cs b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogoVariant.cs new file mode 100644 index 00000000..48624374 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Brand/UAuthLogoVariant.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Docs.Wasm.Client.Brand; + +public enum UAuthLogoVariant +{ + Brand, + Mono +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/CodeBeam.UltimateAuth.Docs.Wasm.Client.csproj b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/CodeBeam.UltimateAuth.Docs.Wasm.Client.csproj new file mode 100644 index 00000000..a3aa3d95 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/CodeBeam.UltimateAuth.Docs.Wasm.Client.csproj @@ -0,0 +1,18 @@ +๏ปฟ + + + net10.0 + enable + enable + true + Default + true + + + + + + + + + diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor new file mode 100644 index 00000000..f0a2d1ce --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor @@ -0,0 +1,55 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Docs.Wasm.Client.Brand +@inherits LayoutComponentBase + +@inject NavigationManager Nav + + + + + + + + +
+
+
+ +
+ +
+ Docs (Preparing) + Donate +
+
+ +
+
+ +
+ + +
+
+
+ + + + Preparing + + + + + + @Body + + +
+ +
+ +
+ An unhandled error has occurred. + Reload + ๐Ÿ—™ +
diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.cs b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.cs new file mode 100644 index 00000000..2f73f6f5 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.cs @@ -0,0 +1,39 @@ +๏ปฟusing MudBlazor; + +namespace CodeBeam.UltimateAuth.Docs.Wasm.Client.Layout; + +public partial class MainLayout +{ + private bool _drawerOpen = false; + private bool _isDarkMode = true; + private bool _visionOverlay = false; + + public void SetVisionOverlay(bool value) + { + _visionOverlay = value; + StateHasChanged(); + } + + MudTheme _uauthTheme = new MudTheme() + { + PaletteLight = new PaletteLight() + { + Primary = "#0C1618", + Secondary = "#f6f5ae", + Tertiary = "#8CE38C", + }, + + PaletteDark = new PaletteDark() + { + Primary = "#FBFEFB", + PrimaryContrastText = "#0C1618", + Secondary = "#2E2D4D", + Tertiary = "#8CE38C", + + TextPrimary = "#FBFEFB", + TextSecondary = "#DEF7DE", + + Background = "#0C1618" + }, + }; +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.css new file mode 100644 index 00000000..60cec92d --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Layout/MainLayout.razor.css @@ -0,0 +1,20 @@ +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor new file mode 100644 index 00000000..b6b11844 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/Home.razor @@ -0,0 +1,145 @@ +๏ปฟ@page "/" +@inject IJSRuntime JS + + + + + UltimateAuth + + The + modern unified Auth framework for .NET โ€“ Reimagined. + + + + + + UltimateAuth is an + open-source + auth framework with platform-level capabilities that unifies secure session, cookie and token based Auth, modern PKCE flows, + Blazor/Maui-ready + client experiences - eliminating the complexity of traditional Auth systems while providing a clean, lightweight, extensible and developer-first architecture. + + + A CodeBeam Product + + + + +
+ + + + + Why UltimateAuth? + + Built for modern .NET applications with simplicity, flexibility and security at its core. + + + + + @foreach (var item in Principles) + { + + + + + + + @item.Title + + + + @item.Description + + + + + } + + + + +
+ + + + + Vision of UltimateAuth + + + + AUTHENTICATION IS COMPLEX.
+ USING IT SHOULDNโ€™T BE. +
+ + + Security is not about a single โ€œcorrectโ€ flow. + Itโ€™s about adapting to context โ€” users, devices, environments and risk. + + + + Flexibility brings real security. + + + + Built from real-world Blazor and .NET application needs โ€” not theoretical models. + +
+
+
+ +
+ + + + + + + UltimateAuth is and will always forever free. + No licenses. No tiers. No hidden limits. Full features as a self-hosted framework. + + But building and maintaining a secure, production-ready Auth framework takes time, effort and care. + + While UltimateAuth secures your applications, you can secure its future. + + If you believe in this vision, consider supporting the project. + + + Support the Project + + + + + +@code { + private List Principles = new() + { + new(Icons.Material.Outlined.Diversity2, "Unified Auth Model", + "Stop choosing between cookies, tokens or sessions. UltimateAuth unifies them into a single model โ€” no more fragmented authentication logic."), + + new(Icons.Material.Outlined.Adjust, "Zero Auth Boilerplate", + "Authentication just works. No manual cookie handling, no token plumbing โ€” UltimateAuth handles it for you."), + + new(Icons.Material.Outlined.Devices, "Device-Aware Sessions", + "Understand and control user sessions across devices with a built-in Root, Chain and Session model."), + + new(Icons.Material.Outlined.AddModerator, "Modern Security", + "Built-in PKCE, secure flows and extensible validation pipelines โ€” secure by default, not by configuration."), + + new(Icons.Material.Outlined.Assistant, "Flexible Modes", + "PureOpaque, Hybrid, SemiHybrid and PureJwt modes allow adapting to different architectures."), + + new(Icons.Material.Outlined.DashboardCustomize, "Extensible", + "Override any part of the pipeline without breaking security boundaries."), + + new(Icons.Material.Outlined.Category, "Developer First", + "Clean APIs, Blazor native design and seamless integration with .NET ecosystem.") + }; + + record Principle(string Icon, string Title, string Description); + + [CascadingParameter] + public MainLayout Layout { get; set; } = default!; +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/NotFound.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/NotFound.razor new file mode 100644 index 00000000..917ada1d --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Pages/NotFound.razor @@ -0,0 +1,5 @@ +๏ปฟ@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Program.cs b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Program.cs new file mode 100644 index 00000000..c8dbf753 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MudBlazor.Services; +using MudExtensions.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); + +builder.Services.AddMudServices(); +builder.Services.AddMudExtensions(); + +await builder.Build().RunAsync(); diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Properties/launchSettings.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Properties/launchSettings.json new file mode 100644 index 00000000..557ba8e6 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "CodeBeam.UltimateAuth.Docs.Wasm.Client": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:65365;http://localhost:65366" + } + } +} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Routes.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Routes.razor new file mode 100644 index 00000000..105855d4 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/Routes.razor @@ -0,0 +1,6 @@ +๏ปฟ + + + + + diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/_Imports.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/_Imports.razor new file mode 100644 index 00000000..17fca2e2 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/_Imports.razor @@ -0,0 +1,13 @@ +๏ปฟ@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using CodeBeam.UltimateAuth.Docs.Wasm.Client +@using CodeBeam.UltimateAuth.Docs.Wasm.Client.Layout + +@using MudBlazor +@using MudExtensions diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.Development.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/appsettings.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.csproj b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.csproj new file mode 100644 index 00000000..d53a6763 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + enable + enable + true + + + + + + + + diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/App.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/App.razor new file mode 100644 index 00000000..fd0d0c18 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/App.razor @@ -0,0 +1,29 @@ +๏ปฟ + + + + + + + @* *@ + + + + + + + + @* *@ + + + + + + + + + + + + + diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/Pages/Error.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/Pages/Error.razor new file mode 100644 index 00000000..576cc2d2 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +๏ปฟ@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/_Imports.razor b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/_Imports.razor new file mode 100644 index 00000000..7506edc0 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Components/_Imports.razor @@ -0,0 +1,11 @@ +๏ปฟ@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using CodeBeam.UltimateAuth.Docs.Wasm +@using CodeBeam.UltimateAuth.Docs.Wasm.Client +@using CodeBeam.UltimateAuth.Docs.Wasm.Components diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Program.cs b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Program.cs new file mode 100644 index 00000000..d75216af --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Program.cs @@ -0,0 +1,35 @@ +using CodeBeam.UltimateAuth.Docs.Wasm.Client.Pages; +using CodeBeam.UltimateAuth.Docs.Wasm.Components; +using MudBlazor.Services; +using MudExtensions.Services; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveWebAssemblyComponents(); + +builder.Services.AddMudServices(); +builder.Services.AddMudExtensions(); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.UseWebAssemblyDebugging(); +} +else +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveWebAssemblyRenderMode() + .AddAdditionalAssemblies(typeof(CodeBeam.UltimateAuth.Docs.Wasm.Client._Imports).Assembly); + +app.Run(); diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Properties/launchSettings.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Properties/launchSettings.json new file mode 100644 index 00000000..3e4e3764 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:5086", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:7287;http://localhost:5086", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.Development.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.json b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/UltimateAuth-final-01.png b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/UltimateAuth-final-01.png new file mode 100644 index 00000000..da686529 Binary files /dev/null and b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/UltimateAuth-final-01.png differ diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/app.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/app.css new file mode 100644 index 00000000..32c66ecc --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/app.css @@ -0,0 +1,200 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +.ua-mobile-only { + display: none; +} + +.ua-desktop-only { + display: flex; +} + +@media (max-width: 600px) { + .ua-mobile-only { + display: flex !important; + } + + .ua-desktop-only { + display: none !important; + } +} + +.ua-appbar-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + width: 100%; +} + +.ua-left { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; +} + +.ua-center { + display: flex; + justify-content: center; + pointer-events: none; +} + +.ua-logo { + pointer-events: auto; +} + +.ua-right { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 4px; +} + +.ua-gradient { + background: linear-gradient( 90deg, var(--mud-palette-primary), #5BD75B ); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.ua-card { + transition: all 1s ease; + border-radius: 16px; + background: transparent !important; +} + + .ua-card:hover { + transform: translateY(-4px); + box-shadow: var(--mud-elevation-8); + } + +.ua-appbar { + backdrop-filter: blur(8px); +} + +.ua-separator { + position: relative; + width: 100%; + height: 1px; + margin: 48px 0; + background: linear-gradient( to right, transparent, var(--mud-palette-primary), transparent ); + opacity: 0.6; +} + + .ua-separator::after { + content: ""; + position: absolute; + left: 50%; + transform: translateX(-50%); + top: 0; + width: 40%; + height: 100%; + background: var(--mud-palette-primary); + filter: blur(8px); + opacity: 0.4; + } + +.ua-signature { + opacity: 0.6; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: 0.7rem; +} + +.ua-vision-title { + font-weight: 800; + letter-spacing: 0.05em; +} + +.ua-vision-text { + opacity: 0.8; + max-width: 600px; + margin: 0 auto; +} + +.ua-vision-highlight { + color: var(--mud-palette-primary); + font-weight: 600; +} + +.ua-vision-footer { + opacity: 0.6; +} + +.ua-vision { + position: relative; +} + +.ua-vision-icon { + transition: transform 0.25s ease; +} + + .ua-vision-icon:hover { + transform: scale(1.15); + } + +.ua-global-overlay { + position: fixed; + inset: 0; + z-index: 9998; + pointer-events: none; + opacity: 0; + transition: opacity 0.35s ease; + background: radial-gradient( circle at 50% 40%, transparent 90px, rgba(0, 0, 0, 0.99) 600px ); +} + + .ua-global-overlay.active { + opacity: 1; + } + +.ua-vision { + position: relative; + z-index: 9999; +} diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/favicon.png b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/favicon.png new file mode 100644 index 00000000..8422b596 Binary files /dev/null and b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/favicon.png differ diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css new file mode 100644 index 00000000..3882a819 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css @@ -0,0 +1,4085 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container-sm, .container { + max-width: 540px; + } +} +@media (min-width: 768px) { + .container-md, .container-sm, .container { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container-lg, .container-md, .container-sm, .container { + max-width: 960px; + } +} +@media (min-width: 1200px) { + .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1140px; + } +} +@media (min-width: 1400px) { + .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1320px; + } +} +:root { + --bs-breakpoint-xs: 0; + --bs-breakpoint-sm: 576px; + --bs-breakpoint-md: 768px; + --bs-breakpoint-lg: 992px; + --bs-breakpoint-xl: 1200px; + --bs-breakpoint-xxl: 1400px; +} + +.row { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + display: flex; + flex-wrap: wrap; + margin-top: calc(-1 * var(--bs-gutter-y)); + margin-right: calc(-0.5 * var(--bs-gutter-x)); + margin-left: calc(-0.5 * var(--bs-gutter-x)); +} +.row > * { + box-sizing: border-box; + flex-shrink: 0; + width: 100%; + max-width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-top: var(--bs-gutter-y); +} + +.col { + flex: 1 0 0%; +} + +.row-cols-auto > * { + flex: 0 0 auto; + width: auto; +} + +.row-cols-1 > * { + flex: 0 0 auto; + width: 100%; +} + +.row-cols-2 > * { + flex: 0 0 auto; + width: 50%; +} + +.row-cols-3 > * { + flex: 0 0 auto; + width: 33.33333333%; +} + +.row-cols-4 > * { + flex: 0 0 auto; + width: 25%; +} + +.row-cols-5 > * { + flex: 0 0 auto; + width: 20%; +} + +.row-cols-6 > * { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-auto { + flex: 0 0 auto; + width: auto; +} + +.col-1 { + flex: 0 0 auto; + width: 8.33333333%; +} + +.col-2 { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-3 { + flex: 0 0 auto; + width: 25%; +} + +.col-4 { + flex: 0 0 auto; + width: 33.33333333%; +} + +.col-5 { + flex: 0 0 auto; + width: 41.66666667%; +} + +.col-6 { + flex: 0 0 auto; + width: 50%; +} + +.col-7 { + flex: 0 0 auto; + width: 58.33333333%; +} + +.col-8 { + flex: 0 0 auto; + width: 66.66666667%; +} + +.col-9 { + flex: 0 0 auto; + width: 75%; +} + +.col-10 { + flex: 0 0 auto; + width: 83.33333333%; +} + +.col-11 { + flex: 0 0 auto; + width: 91.66666667%; +} + +.col-12 { + flex: 0 0 auto; + width: 100%; +} + +.offset-1 { + margin-left: 8.33333333%; +} + +.offset-2 { + margin-left: 16.66666667%; +} + +.offset-3 { + margin-left: 25%; +} + +.offset-4 { + margin-left: 33.33333333%; +} + +.offset-5 { + margin-left: 41.66666667%; +} + +.offset-6 { + margin-left: 50%; +} + +.offset-7 { + margin-left: 58.33333333%; +} + +.offset-8 { + margin-left: 66.66666667%; +} + +.offset-9 { + margin-left: 75%; +} + +.offset-10 { + margin-left: 83.33333333%; +} + +.offset-11 { + margin-left: 91.66666667%; +} + +.g-0, +.gx-0 { + --bs-gutter-x: 0; +} + +.g-0, +.gy-0 { + --bs-gutter-y: 0; +} + +.g-1, +.gx-1 { + --bs-gutter-x: 0.25rem; +} + +.g-1, +.gy-1 { + --bs-gutter-y: 0.25rem; +} + +.g-2, +.gx-2 { + --bs-gutter-x: 0.5rem; +} + +.g-2, +.gy-2 { + --bs-gutter-y: 0.5rem; +} + +.g-3, +.gx-3 { + --bs-gutter-x: 1rem; +} + +.g-3, +.gy-3 { + --bs-gutter-y: 1rem; +} + +.g-4, +.gx-4 { + --bs-gutter-x: 1.5rem; +} + +.g-4, +.gy-4 { + --bs-gutter-y: 1.5rem; +} + +.g-5, +.gx-5 { + --bs-gutter-x: 3rem; +} + +.g-5, +.gy-5 { + --bs-gutter-y: 3rem; +} + +@media (min-width: 576px) { + .col-sm { + flex: 1 0 0%; + } + .row-cols-sm-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-sm-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-sm-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-sm-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-sm-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-sm-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-sm-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-auto { + flex: 0 0 auto; + width: auto; + } + .col-sm-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-sm-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-3 { + flex: 0 0 auto; + width: 25%; + } + .col-sm-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-sm-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-sm-6 { + flex: 0 0 auto; + width: 50%; + } + .col-sm-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-sm-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-sm-9 { + flex: 0 0 auto; + width: 75%; + } + .col-sm-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-sm-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-sm-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-sm-0 { + margin-left: 0; + } + .offset-sm-1 { + margin-left: 8.33333333%; + } + .offset-sm-2 { + margin-left: 16.66666667%; + } + .offset-sm-3 { + margin-left: 25%; + } + .offset-sm-4 { + margin-left: 33.33333333%; + } + .offset-sm-5 { + margin-left: 41.66666667%; + } + .offset-sm-6 { + margin-left: 50%; + } + .offset-sm-7 { + margin-left: 58.33333333%; + } + .offset-sm-8 { + margin-left: 66.66666667%; + } + .offset-sm-9 { + margin-left: 75%; + } + .offset-sm-10 { + margin-left: 83.33333333%; + } + .offset-sm-11 { + margin-left: 91.66666667%; + } + .g-sm-0, + .gx-sm-0 { + --bs-gutter-x: 0; + } + .g-sm-0, + .gy-sm-0 { + --bs-gutter-y: 0; + } + .g-sm-1, + .gx-sm-1 { + --bs-gutter-x: 0.25rem; + } + .g-sm-1, + .gy-sm-1 { + --bs-gutter-y: 0.25rem; + } + .g-sm-2, + .gx-sm-2 { + --bs-gutter-x: 0.5rem; + } + .g-sm-2, + .gy-sm-2 { + --bs-gutter-y: 0.5rem; + } + .g-sm-3, + .gx-sm-3 { + --bs-gutter-x: 1rem; + } + .g-sm-3, + .gy-sm-3 { + --bs-gutter-y: 1rem; + } + .g-sm-4, + .gx-sm-4 { + --bs-gutter-x: 1.5rem; + } + .g-sm-4, + .gy-sm-4 { + --bs-gutter-y: 1.5rem; + } + .g-sm-5, + .gx-sm-5 { + --bs-gutter-x: 3rem; + } + .g-sm-5, + .gy-sm-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 768px) { + .col-md { + flex: 1 0 0%; + } + .row-cols-md-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-md-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-md-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-md-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-md-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-md-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-md-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-auto { + flex: 0 0 auto; + width: auto; + } + .col-md-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-md-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-3 { + flex: 0 0 auto; + width: 25%; + } + .col-md-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-md-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-md-6 { + flex: 0 0 auto; + width: 50%; + } + .col-md-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-md-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-md-9 { + flex: 0 0 auto; + width: 75%; + } + .col-md-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-md-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-md-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-md-0 { + margin-left: 0; + } + .offset-md-1 { + margin-left: 8.33333333%; + } + .offset-md-2 { + margin-left: 16.66666667%; + } + .offset-md-3 { + margin-left: 25%; + } + .offset-md-4 { + margin-left: 33.33333333%; + } + .offset-md-5 { + margin-left: 41.66666667%; + } + .offset-md-6 { + margin-left: 50%; + } + .offset-md-7 { + margin-left: 58.33333333%; + } + .offset-md-8 { + margin-left: 66.66666667%; + } + .offset-md-9 { + margin-left: 75%; + } + .offset-md-10 { + margin-left: 83.33333333%; + } + .offset-md-11 { + margin-left: 91.66666667%; + } + .g-md-0, + .gx-md-0 { + --bs-gutter-x: 0; + } + .g-md-0, + .gy-md-0 { + --bs-gutter-y: 0; + } + .g-md-1, + .gx-md-1 { + --bs-gutter-x: 0.25rem; + } + .g-md-1, + .gy-md-1 { + --bs-gutter-y: 0.25rem; + } + .g-md-2, + .gx-md-2 { + --bs-gutter-x: 0.5rem; + } + .g-md-2, + .gy-md-2 { + --bs-gutter-y: 0.5rem; + } + .g-md-3, + .gx-md-3 { + --bs-gutter-x: 1rem; + } + .g-md-3, + .gy-md-3 { + --bs-gutter-y: 1rem; + } + .g-md-4, + .gx-md-4 { + --bs-gutter-x: 1.5rem; + } + .g-md-4, + .gy-md-4 { + --bs-gutter-y: 1.5rem; + } + .g-md-5, + .gx-md-5 { + --bs-gutter-x: 3rem; + } + .g-md-5, + .gy-md-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 992px) { + .col-lg { + flex: 1 0 0%; + } + .row-cols-lg-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-lg-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-lg-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-lg-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-lg-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-lg-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-lg-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-auto { + flex: 0 0 auto; + width: auto; + } + .col-lg-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-lg-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-3 { + flex: 0 0 auto; + width: 25%; + } + .col-lg-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-lg-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-lg-6 { + flex: 0 0 auto; + width: 50%; + } + .col-lg-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-lg-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-lg-9 { + flex: 0 0 auto; + width: 75%; + } + .col-lg-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-lg-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-lg-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-lg-0 { + margin-left: 0; + } + .offset-lg-1 { + margin-left: 8.33333333%; + } + .offset-lg-2 { + margin-left: 16.66666667%; + } + .offset-lg-3 { + margin-left: 25%; + } + .offset-lg-4 { + margin-left: 33.33333333%; + } + .offset-lg-5 { + margin-left: 41.66666667%; + } + .offset-lg-6 { + margin-left: 50%; + } + .offset-lg-7 { + margin-left: 58.33333333%; + } + .offset-lg-8 { + margin-left: 66.66666667%; + } + .offset-lg-9 { + margin-left: 75%; + } + .offset-lg-10 { + margin-left: 83.33333333%; + } + .offset-lg-11 { + margin-left: 91.66666667%; + } + .g-lg-0, + .gx-lg-0 { + --bs-gutter-x: 0; + } + .g-lg-0, + .gy-lg-0 { + --bs-gutter-y: 0; + } + .g-lg-1, + .gx-lg-1 { + --bs-gutter-x: 0.25rem; + } + .g-lg-1, + .gy-lg-1 { + --bs-gutter-y: 0.25rem; + } + .g-lg-2, + .gx-lg-2 { + --bs-gutter-x: 0.5rem; + } + .g-lg-2, + .gy-lg-2 { + --bs-gutter-y: 0.5rem; + } + .g-lg-3, + .gx-lg-3 { + --bs-gutter-x: 1rem; + } + .g-lg-3, + .gy-lg-3 { + --bs-gutter-y: 1rem; + } + .g-lg-4, + .gx-lg-4 { + --bs-gutter-x: 1.5rem; + } + .g-lg-4, + .gy-lg-4 { + --bs-gutter-y: 1.5rem; + } + .g-lg-5, + .gx-lg-5 { + --bs-gutter-x: 3rem; + } + .g-lg-5, + .gy-lg-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1200px) { + .col-xl { + flex: 1 0 0%; + } + .row-cols-xl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xl-0 { + margin-left: 0; + } + .offset-xl-1 { + margin-left: 8.33333333%; + } + .offset-xl-2 { + margin-left: 16.66666667%; + } + .offset-xl-3 { + margin-left: 25%; + } + .offset-xl-4 { + margin-left: 33.33333333%; + } + .offset-xl-5 { + margin-left: 41.66666667%; + } + .offset-xl-6 { + margin-left: 50%; + } + .offset-xl-7 { + margin-left: 58.33333333%; + } + .offset-xl-8 { + margin-left: 66.66666667%; + } + .offset-xl-9 { + margin-left: 75%; + } + .offset-xl-10 { + margin-left: 83.33333333%; + } + .offset-xl-11 { + margin-left: 91.66666667%; + } + .g-xl-0, + .gx-xl-0 { + --bs-gutter-x: 0; + } + .g-xl-0, + .gy-xl-0 { + --bs-gutter-y: 0; + } + .g-xl-1, + .gx-xl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xl-1, + .gy-xl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xl-2, + .gx-xl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xl-2, + .gy-xl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xl-3, + .gx-xl-3 { + --bs-gutter-x: 1rem; + } + .g-xl-3, + .gy-xl-3 { + --bs-gutter-y: 1rem; + } + .g-xl-4, + .gx-xl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xl-4, + .gy-xl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xl-5, + .gx-xl-5 { + --bs-gutter-x: 3rem; + } + .g-xl-5, + .gy-xl-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1400px) { + .col-xxl { + flex: 1 0 0%; + } + .row-cols-xxl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xxl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xxl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xxl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xxl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xxl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xxl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xxl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xxl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xxl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xxl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xxl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xxl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xxl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xxl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xxl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xxl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xxl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xxl-0 { + margin-left: 0; + } + .offset-xxl-1 { + margin-left: 8.33333333%; + } + .offset-xxl-2 { + margin-left: 16.66666667%; + } + .offset-xxl-3 { + margin-left: 25%; + } + .offset-xxl-4 { + margin-left: 33.33333333%; + } + .offset-xxl-5 { + margin-left: 41.66666667%; + } + .offset-xxl-6 { + margin-left: 50%; + } + .offset-xxl-7 { + margin-left: 58.33333333%; + } + .offset-xxl-8 { + margin-left: 66.66666667%; + } + .offset-xxl-9 { + margin-left: 75%; + } + .offset-xxl-10 { + margin-left: 83.33333333%; + } + .offset-xxl-11 { + margin-left: 91.66666667%; + } + .g-xxl-0, + .gx-xxl-0 { + --bs-gutter-x: 0; + } + .g-xxl-0, + .gy-xxl-0 { + --bs-gutter-y: 0; + } + .g-xxl-1, + .gx-xxl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xxl-1, + .gy-xxl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xxl-2, + .gx-xxl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xxl-2, + .gy-xxl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xxl-3, + .gx-xxl-3 { + --bs-gutter-x: 1rem; + } + .g-xxl-3, + .gy-xxl-3 { + --bs-gutter-y: 1rem; + } + .g-xxl-4, + .gx-xxl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xxl-4, + .gy-xxl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xxl-5, + .gx-xxl-5 { + --bs-gutter-x: 3rem; + } + .g-xxl-5, + .gy-xxl-5 { + --bs-gutter-y: 3rem; + } +} +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-grid { + display: grid !important; +} + +.d-inline-grid { + display: inline-grid !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: flex !important; +} + +.d-inline-flex { + display: inline-flex !important; +} + +.d-none { + display: none !important; +} + +.flex-fill { + flex: 1 1 auto !important; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-column { + flex-direction: column !important; +} + +.flex-row-reverse { + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + flex-direction: column-reverse !important; +} + +.flex-grow-0 { + flex-grow: 0 !important; +} + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + flex-shrink: 1 !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + +.flex-nowrap { + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse !important; +} + +.justify-content-start { + justify-content: flex-start !important; +} + +.justify-content-end { + justify-content: flex-end !important; +} + +.justify-content-center { + justify-content: center !important; +} + +.justify-content-between { + justify-content: space-between !important; +} + +.justify-content-around { + justify-content: space-around !important; +} + +.justify-content-evenly { + justify-content: space-evenly !important; +} + +.align-items-start { + align-items: flex-start !important; +} + +.align-items-end { + align-items: flex-end !important; +} + +.align-items-center { + align-items: center !important; +} + +.align-items-baseline { + align-items: baseline !important; +} + +.align-items-stretch { + align-items: stretch !important; +} + +.align-content-start { + align-content: flex-start !important; +} + +.align-content-end { + align-content: flex-end !important; +} + +.align-content-center { + align-content: center !important; +} + +.align-content-between { + align-content: space-between !important; +} + +.align-content-around { + align-content: space-around !important; +} + +.align-content-stretch { + align-content: stretch !important; +} + +.align-self-auto { + align-self: auto !important; +} + +.align-self-start { + align-self: flex-start !important; +} + +.align-self-end { + align-self: flex-end !important; +} + +.align-self-center { + align-self: center !important; +} + +.align-self-baseline { + align-self: baseline !important; +} + +.align-self-stretch { + align-self: stretch !important; +} + +.order-first { + order: -1 !important; +} + +.order-0 { + order: 0 !important; +} + +.order-1 { + order: 1 !important; +} + +.order-2 { + order: 2 !important; +} + +.order-3 { + order: 3 !important; +} + +.order-4 { + order: 4 !important; +} + +.order-5 { + order: 5 !important; +} + +.order-last { + order: 6 !important; +} + +.m-0 { + margin: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mx-0 { + margin-right: 0 !important; + margin-left: 0 !important; +} + +.mx-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; +} + +.mx-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; +} + +.mx-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; +} + +.mx-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; +} + +.mx-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; +} + +.mx-auto { + margin-right: auto !important; + margin-left: auto !important; +} + +.my-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.my-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; +} + +.my-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.my-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.my-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} + +.my-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} + +.my-auto { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.mt-0 { + margin-top: 0 !important; +} + +.mt-1 { + margin-top: 0.25rem !important; +} + +.mt-2 { + margin-top: 0.5rem !important; +} + +.mt-3 { + margin-top: 1rem !important; +} + +.mt-4 { + margin-top: 1.5rem !important; +} + +.mt-5 { + margin-top: 3rem !important; +} + +.mt-auto { + margin-top: auto !important; +} + +.me-0 { + margin-right: 0 !important; +} + +.me-1 { + margin-right: 0.25rem !important; +} + +.me-2 { + margin-right: 0.5rem !important; +} + +.me-3 { + margin-right: 1rem !important; +} + +.me-4 { + margin-right: 1.5rem !important; +} + +.me-5 { + margin-right: 3rem !important; +} + +.me-auto { + margin-right: auto !important; +} + +.mb-0 { + margin-bottom: 0 !important; +} + +.mb-1 { + margin-bottom: 0.25rem !important; +} + +.mb-2 { + margin-bottom: 0.5rem !important; +} + +.mb-3 { + margin-bottom: 1rem !important; +} + +.mb-4 { + margin-bottom: 1.5rem !important; +} + +.mb-5 { + margin-bottom: 3rem !important; +} + +.mb-auto { + margin-bottom: auto !important; +} + +.ms-0 { + margin-left: 0 !important; +} + +.ms-1 { + margin-left: 0.25rem !important; +} + +.ms-2 { + margin-left: 0.5rem !important; +} + +.ms-3 { + margin-left: 1rem !important; +} + +.ms-4 { + margin-left: 1.5rem !important; +} + +.ms-5 { + margin-left: 3rem !important; +} + +.ms-auto { + margin-left: auto !important; +} + +.p-0 { + padding: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.px-0 { + padding-right: 0 !important; + padding-left: 0 !important; +} + +.px-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; +} + +.px-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; +} + +.px-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; +} + +.px-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; +} + +.px-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; +} + +.py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.py-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; +} + +.py-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; +} + +.py-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; +} + +.py-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} + +.pt-0 { + padding-top: 0 !important; +} + +.pt-1 { + padding-top: 0.25rem !important; +} + +.pt-2 { + padding-top: 0.5rem !important; +} + +.pt-3 { + padding-top: 1rem !important; +} + +.pt-4 { + padding-top: 1.5rem !important; +} + +.pt-5 { + padding-top: 3rem !important; +} + +.pe-0 { + padding-right: 0 !important; +} + +.pe-1 { + padding-right: 0.25rem !important; +} + +.pe-2 { + padding-right: 0.5rem !important; +} + +.pe-3 { + padding-right: 1rem !important; +} + +.pe-4 { + padding-right: 1.5rem !important; +} + +.pe-5 { + padding-right: 3rem !important; +} + +.pb-0 { + padding-bottom: 0 !important; +} + +.pb-1 { + padding-bottom: 0.25rem !important; +} + +.pb-2 { + padding-bottom: 0.5rem !important; +} + +.pb-3 { + padding-bottom: 1rem !important; +} + +.pb-4 { + padding-bottom: 1.5rem !important; +} + +.pb-5 { + padding-bottom: 3rem !important; +} + +.ps-0 { + padding-left: 0 !important; +} + +.ps-1 { + padding-left: 0.25rem !important; +} + +.ps-2 { + padding-left: 0.5rem !important; +} + +.ps-3 { + padding-left: 1rem !important; +} + +.ps-4 { + padding-left: 1.5rem !important; +} + +.ps-5 { + padding-left: 3rem !important; +} + +@media (min-width: 576px) { + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { + display: inline-block !important; + } + .d-sm-block { + display: block !important; + } + .d-sm-grid { + display: grid !important; + } + .d-sm-inline-grid { + display: inline-grid !important; + } + .d-sm-table { + display: table !important; + } + .d-sm-table-row { + display: table-row !important; + } + .d-sm-table-cell { + display: table-cell !important; + } + .d-sm-flex { + display: flex !important; + } + .d-sm-inline-flex { + display: inline-flex !important; + } + .d-sm-none { + display: none !important; + } + .flex-sm-fill { + flex: 1 1 auto !important; + } + .flex-sm-row { + flex-direction: row !important; + } + .flex-sm-column { + flex-direction: column !important; + } + .flex-sm-row-reverse { + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + flex-direction: column-reverse !important; + } + .flex-sm-grow-0 { + flex-grow: 0 !important; + } + .flex-sm-grow-1 { + flex-grow: 1 !important; + } + .flex-sm-shrink-0 { + flex-shrink: 0 !important; + } + .flex-sm-shrink-1 { + flex-shrink: 1 !important; + } + .flex-sm-wrap { + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-sm-start { + justify-content: flex-start !important; + } + .justify-content-sm-end { + justify-content: flex-end !important; + } + .justify-content-sm-center { + justify-content: center !important; + } + .justify-content-sm-between { + justify-content: space-between !important; + } + .justify-content-sm-around { + justify-content: space-around !important; + } + .justify-content-sm-evenly { + justify-content: space-evenly !important; + } + .align-items-sm-start { + align-items: flex-start !important; + } + .align-items-sm-end { + align-items: flex-end !important; + } + .align-items-sm-center { + align-items: center !important; + } + .align-items-sm-baseline { + align-items: baseline !important; + } + .align-items-sm-stretch { + align-items: stretch !important; + } + .align-content-sm-start { + align-content: flex-start !important; + } + .align-content-sm-end { + align-content: flex-end !important; + } + .align-content-sm-center { + align-content: center !important; + } + .align-content-sm-between { + align-content: space-between !important; + } + .align-content-sm-around { + align-content: space-around !important; + } + .align-content-sm-stretch { + align-content: stretch !important; + } + .align-self-sm-auto { + align-self: auto !important; + } + .align-self-sm-start { + align-self: flex-start !important; + } + .align-self-sm-end { + align-self: flex-end !important; + } + .align-self-sm-center { + align-self: center !important; + } + .align-self-sm-baseline { + align-self: baseline !important; + } + .align-self-sm-stretch { + align-self: stretch !important; + } + .order-sm-first { + order: -1 !important; + } + .order-sm-0 { + order: 0 !important; + } + .order-sm-1 { + order: 1 !important; + } + .order-sm-2 { + order: 2 !important; + } + .order-sm-3 { + order: 3 !important; + } + .order-sm-4 { + order: 4 !important; + } + .order-sm-5 { + order: 5 !important; + } + .order-sm-last { + order: 6 !important; + } + .m-sm-0 { + margin: 0 !important; + } + .m-sm-1 { + margin: 0.25rem !important; + } + .m-sm-2 { + margin: 0.5rem !important; + } + .m-sm-3 { + margin: 1rem !important; + } + .m-sm-4 { + margin: 1.5rem !important; + } + .m-sm-5 { + margin: 3rem !important; + } + .m-sm-auto { + margin: auto !important; + } + .mx-sm-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-sm-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-sm-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-sm-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-sm-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-sm-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-sm-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-sm-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-sm-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-sm-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-sm-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-sm-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-sm-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-sm-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-sm-0 { + margin-top: 0 !important; + } + .mt-sm-1 { + margin-top: 0.25rem !important; + } + .mt-sm-2 { + margin-top: 0.5rem !important; + } + .mt-sm-3 { + margin-top: 1rem !important; + } + .mt-sm-4 { + margin-top: 1.5rem !important; + } + .mt-sm-5 { + margin-top: 3rem !important; + } + .mt-sm-auto { + margin-top: auto !important; + } + .me-sm-0 { + margin-right: 0 !important; + } + .me-sm-1 { + margin-right: 0.25rem !important; + } + .me-sm-2 { + margin-right: 0.5rem !important; + } + .me-sm-3 { + margin-right: 1rem !important; + } + .me-sm-4 { + margin-right: 1.5rem !important; + } + .me-sm-5 { + margin-right: 3rem !important; + } + .me-sm-auto { + margin-right: auto !important; + } + .mb-sm-0 { + margin-bottom: 0 !important; + } + .mb-sm-1 { + margin-bottom: 0.25rem !important; + } + .mb-sm-2 { + margin-bottom: 0.5rem !important; + } + .mb-sm-3 { + margin-bottom: 1rem !important; + } + .mb-sm-4 { + margin-bottom: 1.5rem !important; + } + .mb-sm-5 { + margin-bottom: 3rem !important; + } + .mb-sm-auto { + margin-bottom: auto !important; + } + .ms-sm-0 { + margin-left: 0 !important; + } + .ms-sm-1 { + margin-left: 0.25rem !important; + } + .ms-sm-2 { + margin-left: 0.5rem !important; + } + .ms-sm-3 { + margin-left: 1rem !important; + } + .ms-sm-4 { + margin-left: 1.5rem !important; + } + .ms-sm-5 { + margin-left: 3rem !important; + } + .ms-sm-auto { + margin-left: auto !important; + } + .p-sm-0 { + padding: 0 !important; + } + .p-sm-1 { + padding: 0.25rem !important; + } + .p-sm-2 { + padding: 0.5rem !important; + } + .p-sm-3 { + padding: 1rem !important; + } + .p-sm-4 { + padding: 1.5rem !important; + } + .p-sm-5 { + padding: 3rem !important; + } + .px-sm-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-sm-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-sm-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-sm-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-sm-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-sm-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-sm-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-sm-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-sm-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-sm-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-sm-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-sm-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-sm-0 { + padding-top: 0 !important; + } + .pt-sm-1 { + padding-top: 0.25rem !important; + } + .pt-sm-2 { + padding-top: 0.5rem !important; + } + .pt-sm-3 { + padding-top: 1rem !important; + } + .pt-sm-4 { + padding-top: 1.5rem !important; + } + .pt-sm-5 { + padding-top: 3rem !important; + } + .pe-sm-0 { + padding-right: 0 !important; + } + .pe-sm-1 { + padding-right: 0.25rem !important; + } + .pe-sm-2 { + padding-right: 0.5rem !important; + } + .pe-sm-3 { + padding-right: 1rem !important; + } + .pe-sm-4 { + padding-right: 1.5rem !important; + } + .pe-sm-5 { + padding-right: 3rem !important; + } + .pb-sm-0 { + padding-bottom: 0 !important; + } + .pb-sm-1 { + padding-bottom: 0.25rem !important; + } + .pb-sm-2 { + padding-bottom: 0.5rem !important; + } + .pb-sm-3 { + padding-bottom: 1rem !important; + } + .pb-sm-4 { + padding-bottom: 1.5rem !important; + } + .pb-sm-5 { + padding-bottom: 3rem !important; + } + .ps-sm-0 { + padding-left: 0 !important; + } + .ps-sm-1 { + padding-left: 0.25rem !important; + } + .ps-sm-2 { + padding-left: 0.5rem !important; + } + .ps-sm-3 { + padding-left: 1rem !important; + } + .ps-sm-4 { + padding-left: 1.5rem !important; + } + .ps-sm-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 768px) { + .d-md-inline { + display: inline !important; + } + .d-md-inline-block { + display: inline-block !important; + } + .d-md-block { + display: block !important; + } + .d-md-grid { + display: grid !important; + } + .d-md-inline-grid { + display: inline-grid !important; + } + .d-md-table { + display: table !important; + } + .d-md-table-row { + display: table-row !important; + } + .d-md-table-cell { + display: table-cell !important; + } + .d-md-flex { + display: flex !important; + } + .d-md-inline-flex { + display: inline-flex !important; + } + .d-md-none { + display: none !important; + } + .flex-md-fill { + flex: 1 1 auto !important; + } + .flex-md-row { + flex-direction: row !important; + } + .flex-md-column { + flex-direction: column !important; + } + .flex-md-row-reverse { + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + flex-direction: column-reverse !important; + } + .flex-md-grow-0 { + flex-grow: 0 !important; + } + .flex-md-grow-1 { + flex-grow: 1 !important; + } + .flex-md-shrink-0 { + flex-shrink: 0 !important; + } + .flex-md-shrink-1 { + flex-shrink: 1 !important; + } + .flex-md-wrap { + flex-wrap: wrap !important; + } + .flex-md-nowrap { + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-md-start { + justify-content: flex-start !important; + } + .justify-content-md-end { + justify-content: flex-end !important; + } + .justify-content-md-center { + justify-content: center !important; + } + .justify-content-md-between { + justify-content: space-between !important; + } + .justify-content-md-around { + justify-content: space-around !important; + } + .justify-content-md-evenly { + justify-content: space-evenly !important; + } + .align-items-md-start { + align-items: flex-start !important; + } + .align-items-md-end { + align-items: flex-end !important; + } + .align-items-md-center { + align-items: center !important; + } + .align-items-md-baseline { + align-items: baseline !important; + } + .align-items-md-stretch { + align-items: stretch !important; + } + .align-content-md-start { + align-content: flex-start !important; + } + .align-content-md-end { + align-content: flex-end !important; + } + .align-content-md-center { + align-content: center !important; + } + .align-content-md-between { + align-content: space-between !important; + } + .align-content-md-around { + align-content: space-around !important; + } + .align-content-md-stretch { + align-content: stretch !important; + } + .align-self-md-auto { + align-self: auto !important; + } + .align-self-md-start { + align-self: flex-start !important; + } + .align-self-md-end { + align-self: flex-end !important; + } + .align-self-md-center { + align-self: center !important; + } + .align-self-md-baseline { + align-self: baseline !important; + } + .align-self-md-stretch { + align-self: stretch !important; + } + .order-md-first { + order: -1 !important; + } + .order-md-0 { + order: 0 !important; + } + .order-md-1 { + order: 1 !important; + } + .order-md-2 { + order: 2 !important; + } + .order-md-3 { + order: 3 !important; + } + .order-md-4 { + order: 4 !important; + } + .order-md-5 { + order: 5 !important; + } + .order-md-last { + order: 6 !important; + } + .m-md-0 { + margin: 0 !important; + } + .m-md-1 { + margin: 0.25rem !important; + } + .m-md-2 { + margin: 0.5rem !important; + } + .m-md-3 { + margin: 1rem !important; + } + .m-md-4 { + margin: 1.5rem !important; + } + .m-md-5 { + margin: 3rem !important; + } + .m-md-auto { + margin: auto !important; + } + .mx-md-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-md-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-md-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-md-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-md-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-md-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-md-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-md-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-md-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-md-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-md-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-md-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-md-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-md-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-md-0 { + margin-top: 0 !important; + } + .mt-md-1 { + margin-top: 0.25rem !important; + } + .mt-md-2 { + margin-top: 0.5rem !important; + } + .mt-md-3 { + margin-top: 1rem !important; + } + .mt-md-4 { + margin-top: 1.5rem !important; + } + .mt-md-5 { + margin-top: 3rem !important; + } + .mt-md-auto { + margin-top: auto !important; + } + .me-md-0 { + margin-right: 0 !important; + } + .me-md-1 { + margin-right: 0.25rem !important; + } + .me-md-2 { + margin-right: 0.5rem !important; + } + .me-md-3 { + margin-right: 1rem !important; + } + .me-md-4 { + margin-right: 1.5rem !important; + } + .me-md-5 { + margin-right: 3rem !important; + } + .me-md-auto { + margin-right: auto !important; + } + .mb-md-0 { + margin-bottom: 0 !important; + } + .mb-md-1 { + margin-bottom: 0.25rem !important; + } + .mb-md-2 { + margin-bottom: 0.5rem !important; + } + .mb-md-3 { + margin-bottom: 1rem !important; + } + .mb-md-4 { + margin-bottom: 1.5rem !important; + } + .mb-md-5 { + margin-bottom: 3rem !important; + } + .mb-md-auto { + margin-bottom: auto !important; + } + .ms-md-0 { + margin-left: 0 !important; + } + .ms-md-1 { + margin-left: 0.25rem !important; + } + .ms-md-2 { + margin-left: 0.5rem !important; + } + .ms-md-3 { + margin-left: 1rem !important; + } + .ms-md-4 { + margin-left: 1.5rem !important; + } + .ms-md-5 { + margin-left: 3rem !important; + } + .ms-md-auto { + margin-left: auto !important; + } + .p-md-0 { + padding: 0 !important; + } + .p-md-1 { + padding: 0.25rem !important; + } + .p-md-2 { + padding: 0.5rem !important; + } + .p-md-3 { + padding: 1rem !important; + } + .p-md-4 { + padding: 1.5rem !important; + } + .p-md-5 { + padding: 3rem !important; + } + .px-md-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-md-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-md-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-md-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-md-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-md-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-md-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-md-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-md-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-md-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-md-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-md-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-md-0 { + padding-top: 0 !important; + } + .pt-md-1 { + padding-top: 0.25rem !important; + } + .pt-md-2 { + padding-top: 0.5rem !important; + } + .pt-md-3 { + padding-top: 1rem !important; + } + .pt-md-4 { + padding-top: 1.5rem !important; + } + .pt-md-5 { + padding-top: 3rem !important; + } + .pe-md-0 { + padding-right: 0 !important; + } + .pe-md-1 { + padding-right: 0.25rem !important; + } + .pe-md-2 { + padding-right: 0.5rem !important; + } + .pe-md-3 { + padding-right: 1rem !important; + } + .pe-md-4 { + padding-right: 1.5rem !important; + } + .pe-md-5 { + padding-right: 3rem !important; + } + .pb-md-0 { + padding-bottom: 0 !important; + } + .pb-md-1 { + padding-bottom: 0.25rem !important; + } + .pb-md-2 { + padding-bottom: 0.5rem !important; + } + .pb-md-3 { + padding-bottom: 1rem !important; + } + .pb-md-4 { + padding-bottom: 1.5rem !important; + } + .pb-md-5 { + padding-bottom: 3rem !important; + } + .ps-md-0 { + padding-left: 0 !important; + } + .ps-md-1 { + padding-left: 0.25rem !important; + } + .ps-md-2 { + padding-left: 0.5rem !important; + } + .ps-md-3 { + padding-left: 1rem !important; + } + .ps-md-4 { + padding-left: 1.5rem !important; + } + .ps-md-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 992px) { + .d-lg-inline { + display: inline !important; + } + .d-lg-inline-block { + display: inline-block !important; + } + .d-lg-block { + display: block !important; + } + .d-lg-grid { + display: grid !important; + } + .d-lg-inline-grid { + display: inline-grid !important; + } + .d-lg-table { + display: table !important; + } + .d-lg-table-row { + display: table-row !important; + } + .d-lg-table-cell { + display: table-cell !important; + } + .d-lg-flex { + display: flex !important; + } + .d-lg-inline-flex { + display: inline-flex !important; + } + .d-lg-none { + display: none !important; + } + .flex-lg-fill { + flex: 1 1 auto !important; + } + .flex-lg-row { + flex-direction: row !important; + } + .flex-lg-column { + flex-direction: column !important; + } + .flex-lg-row-reverse { + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + flex-direction: column-reverse !important; + } + .flex-lg-grow-0 { + flex-grow: 0 !important; + } + .flex-lg-grow-1 { + flex-grow: 1 !important; + } + .flex-lg-shrink-0 { + flex-shrink: 0 !important; + } + .flex-lg-shrink-1 { + flex-shrink: 1 !important; + } + .flex-lg-wrap { + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-lg-start { + justify-content: flex-start !important; + } + .justify-content-lg-end { + justify-content: flex-end !important; + } + .justify-content-lg-center { + justify-content: center !important; + } + .justify-content-lg-between { + justify-content: space-between !important; + } + .justify-content-lg-around { + justify-content: space-around !important; + } + .justify-content-lg-evenly { + justify-content: space-evenly !important; + } + .align-items-lg-start { + align-items: flex-start !important; + } + .align-items-lg-end { + align-items: flex-end !important; + } + .align-items-lg-center { + align-items: center !important; + } + .align-items-lg-baseline { + align-items: baseline !important; + } + .align-items-lg-stretch { + align-items: stretch !important; + } + .align-content-lg-start { + align-content: flex-start !important; + } + .align-content-lg-end { + align-content: flex-end !important; + } + .align-content-lg-center { + align-content: center !important; + } + .align-content-lg-between { + align-content: space-between !important; + } + .align-content-lg-around { + align-content: space-around !important; + } + .align-content-lg-stretch { + align-content: stretch !important; + } + .align-self-lg-auto { + align-self: auto !important; + } + .align-self-lg-start { + align-self: flex-start !important; + } + .align-self-lg-end { + align-self: flex-end !important; + } + .align-self-lg-center { + align-self: center !important; + } + .align-self-lg-baseline { + align-self: baseline !important; + } + .align-self-lg-stretch { + align-self: stretch !important; + } + .order-lg-first { + order: -1 !important; + } + .order-lg-0 { + order: 0 !important; + } + .order-lg-1 { + order: 1 !important; + } + .order-lg-2 { + order: 2 !important; + } + .order-lg-3 { + order: 3 !important; + } + .order-lg-4 { + order: 4 !important; + } + .order-lg-5 { + order: 5 !important; + } + .order-lg-last { + order: 6 !important; + } + .m-lg-0 { + margin: 0 !important; + } + .m-lg-1 { + margin: 0.25rem !important; + } + .m-lg-2 { + margin: 0.5rem !important; + } + .m-lg-3 { + margin: 1rem !important; + } + .m-lg-4 { + margin: 1.5rem !important; + } + .m-lg-5 { + margin: 3rem !important; + } + .m-lg-auto { + margin: auto !important; + } + .mx-lg-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-lg-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-lg-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-lg-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-lg-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-lg-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-lg-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-lg-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-lg-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-lg-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-lg-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-lg-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-lg-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-lg-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-lg-0 { + margin-top: 0 !important; + } + .mt-lg-1 { + margin-top: 0.25rem !important; + } + .mt-lg-2 { + margin-top: 0.5rem !important; + } + .mt-lg-3 { + margin-top: 1rem !important; + } + .mt-lg-4 { + margin-top: 1.5rem !important; + } + .mt-lg-5 { + margin-top: 3rem !important; + } + .mt-lg-auto { + margin-top: auto !important; + } + .me-lg-0 { + margin-right: 0 !important; + } + .me-lg-1 { + margin-right: 0.25rem !important; + } + .me-lg-2 { + margin-right: 0.5rem !important; + } + .me-lg-3 { + margin-right: 1rem !important; + } + .me-lg-4 { + margin-right: 1.5rem !important; + } + .me-lg-5 { + margin-right: 3rem !important; + } + .me-lg-auto { + margin-right: auto !important; + } + .mb-lg-0 { + margin-bottom: 0 !important; + } + .mb-lg-1 { + margin-bottom: 0.25rem !important; + } + .mb-lg-2 { + margin-bottom: 0.5rem !important; + } + .mb-lg-3 { + margin-bottom: 1rem !important; + } + .mb-lg-4 { + margin-bottom: 1.5rem !important; + } + .mb-lg-5 { + margin-bottom: 3rem !important; + } + .mb-lg-auto { + margin-bottom: auto !important; + } + .ms-lg-0 { + margin-left: 0 !important; + } + .ms-lg-1 { + margin-left: 0.25rem !important; + } + .ms-lg-2 { + margin-left: 0.5rem !important; + } + .ms-lg-3 { + margin-left: 1rem !important; + } + .ms-lg-4 { + margin-left: 1.5rem !important; + } + .ms-lg-5 { + margin-left: 3rem !important; + } + .ms-lg-auto { + margin-left: auto !important; + } + .p-lg-0 { + padding: 0 !important; + } + .p-lg-1 { + padding: 0.25rem !important; + } + .p-lg-2 { + padding: 0.5rem !important; + } + .p-lg-3 { + padding: 1rem !important; + } + .p-lg-4 { + padding: 1.5rem !important; + } + .p-lg-5 { + padding: 3rem !important; + } + .px-lg-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-lg-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-lg-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-lg-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-lg-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-lg-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-lg-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-lg-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-lg-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-lg-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-lg-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-lg-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-lg-0 { + padding-top: 0 !important; + } + .pt-lg-1 { + padding-top: 0.25rem !important; + } + .pt-lg-2 { + padding-top: 0.5rem !important; + } + .pt-lg-3 { + padding-top: 1rem !important; + } + .pt-lg-4 { + padding-top: 1.5rem !important; + } + .pt-lg-5 { + padding-top: 3rem !important; + } + .pe-lg-0 { + padding-right: 0 !important; + } + .pe-lg-1 { + padding-right: 0.25rem !important; + } + .pe-lg-2 { + padding-right: 0.5rem !important; + } + .pe-lg-3 { + padding-right: 1rem !important; + } + .pe-lg-4 { + padding-right: 1.5rem !important; + } + .pe-lg-5 { + padding-right: 3rem !important; + } + .pb-lg-0 { + padding-bottom: 0 !important; + } + .pb-lg-1 { + padding-bottom: 0.25rem !important; + } + .pb-lg-2 { + padding-bottom: 0.5rem !important; + } + .pb-lg-3 { + padding-bottom: 1rem !important; + } + .pb-lg-4 { + padding-bottom: 1.5rem !important; + } + .pb-lg-5 { + padding-bottom: 3rem !important; + } + .ps-lg-0 { + padding-left: 0 !important; + } + .ps-lg-1 { + padding-left: 0.25rem !important; + } + .ps-lg-2 { + padding-left: 0.5rem !important; + } + .ps-lg-3 { + padding-left: 1rem !important; + } + .ps-lg-4 { + padding-left: 1.5rem !important; + } + .ps-lg-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 1200px) { + .d-xl-inline { + display: inline !important; + } + .d-xl-inline-block { + display: inline-block !important; + } + .d-xl-block { + display: block !important; + } + .d-xl-grid { + display: grid !important; + } + .d-xl-inline-grid { + display: inline-grid !important; + } + .d-xl-table { + display: table !important; + } + .d-xl-table-row { + display: table-row !important; + } + .d-xl-table-cell { + display: table-cell !important; + } + .d-xl-flex { + display: flex !important; + } + .d-xl-inline-flex { + display: inline-flex !important; + } + .d-xl-none { + display: none !important; + } + .flex-xl-fill { + flex: 1 1 auto !important; + } + .flex-xl-row { + flex-direction: row !important; + } + .flex-xl-column { + flex-direction: column !important; + } + .flex-xl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xl-grow-0 { + flex-grow: 0 !important; + } + .flex-xl-grow-1 { + flex-grow: 1 !important; + } + .flex-xl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xl-wrap { + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xl-start { + justify-content: flex-start !important; + } + .justify-content-xl-end { + justify-content: flex-end !important; + } + .justify-content-xl-center { + justify-content: center !important; + } + .justify-content-xl-between { + justify-content: space-between !important; + } + .justify-content-xl-around { + justify-content: space-around !important; + } + .justify-content-xl-evenly { + justify-content: space-evenly !important; + } + .align-items-xl-start { + align-items: flex-start !important; + } + .align-items-xl-end { + align-items: flex-end !important; + } + .align-items-xl-center { + align-items: center !important; + } + .align-items-xl-baseline { + align-items: baseline !important; + } + .align-items-xl-stretch { + align-items: stretch !important; + } + .align-content-xl-start { + align-content: flex-start !important; + } + .align-content-xl-end { + align-content: flex-end !important; + } + .align-content-xl-center { + align-content: center !important; + } + .align-content-xl-between { + align-content: space-between !important; + } + .align-content-xl-around { + align-content: space-around !important; + } + .align-content-xl-stretch { + align-content: stretch !important; + } + .align-self-xl-auto { + align-self: auto !important; + } + .align-self-xl-start { + align-self: flex-start !important; + } + .align-self-xl-end { + align-self: flex-end !important; + } + .align-self-xl-center { + align-self: center !important; + } + .align-self-xl-baseline { + align-self: baseline !important; + } + .align-self-xl-stretch { + align-self: stretch !important; + } + .order-xl-first { + order: -1 !important; + } + .order-xl-0 { + order: 0 !important; + } + .order-xl-1 { + order: 1 !important; + } + .order-xl-2 { + order: 2 !important; + } + .order-xl-3 { + order: 3 !important; + } + .order-xl-4 { + order: 4 !important; + } + .order-xl-5 { + order: 5 !important; + } + .order-xl-last { + order: 6 !important; + } + .m-xl-0 { + margin: 0 !important; + } + .m-xl-1 { + margin: 0.25rem !important; + } + .m-xl-2 { + margin: 0.5rem !important; + } + .m-xl-3 { + margin: 1rem !important; + } + .m-xl-4 { + margin: 1.5rem !important; + } + .m-xl-5 { + margin: 3rem !important; + } + .m-xl-auto { + margin: auto !important; + } + .mx-xl-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-xl-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-xl-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-xl-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-xl-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-xl-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-xl-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-xl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xl-0 { + margin-top: 0 !important; + } + .mt-xl-1 { + margin-top: 0.25rem !important; + } + .mt-xl-2 { + margin-top: 0.5rem !important; + } + .mt-xl-3 { + margin-top: 1rem !important; + } + .mt-xl-4 { + margin-top: 1.5rem !important; + } + .mt-xl-5 { + margin-top: 3rem !important; + } + .mt-xl-auto { + margin-top: auto !important; + } + .me-xl-0 { + margin-right: 0 !important; + } + .me-xl-1 { + margin-right: 0.25rem !important; + } + .me-xl-2 { + margin-right: 0.5rem !important; + } + .me-xl-3 { + margin-right: 1rem !important; + } + .me-xl-4 { + margin-right: 1.5rem !important; + } + .me-xl-5 { + margin-right: 3rem !important; + } + .me-xl-auto { + margin-right: auto !important; + } + .mb-xl-0 { + margin-bottom: 0 !important; + } + .mb-xl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xl-3 { + margin-bottom: 1rem !important; + } + .mb-xl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xl-5 { + margin-bottom: 3rem !important; + } + .mb-xl-auto { + margin-bottom: auto !important; + } + .ms-xl-0 { + margin-left: 0 !important; + } + .ms-xl-1 { + margin-left: 0.25rem !important; + } + .ms-xl-2 { + margin-left: 0.5rem !important; + } + .ms-xl-3 { + margin-left: 1rem !important; + } + .ms-xl-4 { + margin-left: 1.5rem !important; + } + .ms-xl-5 { + margin-left: 3rem !important; + } + .ms-xl-auto { + margin-left: auto !important; + } + .p-xl-0 { + padding: 0 !important; + } + .p-xl-1 { + padding: 0.25rem !important; + } + .p-xl-2 { + padding: 0.5rem !important; + } + .p-xl-3 { + padding: 1rem !important; + } + .p-xl-4 { + padding: 1.5rem !important; + } + .p-xl-5 { + padding: 3rem !important; + } + .px-xl-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-xl-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-xl-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-xl-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-xl-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-xl-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-xl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xl-0 { + padding-top: 0 !important; + } + .pt-xl-1 { + padding-top: 0.25rem !important; + } + .pt-xl-2 { + padding-top: 0.5rem !important; + } + .pt-xl-3 { + padding-top: 1rem !important; + } + .pt-xl-4 { + padding-top: 1.5rem !important; + } + .pt-xl-5 { + padding-top: 3rem !important; + } + .pe-xl-0 { + padding-right: 0 !important; + } + .pe-xl-1 { + padding-right: 0.25rem !important; + } + .pe-xl-2 { + padding-right: 0.5rem !important; + } + .pe-xl-3 { + padding-right: 1rem !important; + } + .pe-xl-4 { + padding-right: 1.5rem !important; + } + .pe-xl-5 { + padding-right: 3rem !important; + } + .pb-xl-0 { + padding-bottom: 0 !important; + } + .pb-xl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xl-3 { + padding-bottom: 1rem !important; + } + .pb-xl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xl-5 { + padding-bottom: 3rem !important; + } + .ps-xl-0 { + padding-left: 0 !important; + } + .ps-xl-1 { + padding-left: 0.25rem !important; + } + .ps-xl-2 { + padding-left: 0.5rem !important; + } + .ps-xl-3 { + padding-left: 1rem !important; + } + .ps-xl-4 { + padding-left: 1.5rem !important; + } + .ps-xl-5 { + padding-left: 3rem !important; + } +} +@media (min-width: 1400px) { + .d-xxl-inline { + display: inline !important; + } + .d-xxl-inline-block { + display: inline-block !important; + } + .d-xxl-block { + display: block !important; + } + .d-xxl-grid { + display: grid !important; + } + .d-xxl-inline-grid { + display: inline-grid !important; + } + .d-xxl-table { + display: table !important; + } + .d-xxl-table-row { + display: table-row !important; + } + .d-xxl-table-cell { + display: table-cell !important; + } + .d-xxl-flex { + display: flex !important; + } + .d-xxl-inline-flex { + display: inline-flex !important; + } + .d-xxl-none { + display: none !important; + } + .flex-xxl-fill { + flex: 1 1 auto !important; + } + .flex-xxl-row { + flex-direction: row !important; + } + .flex-xxl-column { + flex-direction: column !important; + } + .flex-xxl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xxl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xxl-grow-0 { + flex-grow: 0 !important; + } + .flex-xxl-grow-1 { + flex-grow: 1 !important; + } + .flex-xxl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xxl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xxl-wrap { + flex-wrap: wrap !important; + } + .flex-xxl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xxl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xxl-start { + justify-content: flex-start !important; + } + .justify-content-xxl-end { + justify-content: flex-end !important; + } + .justify-content-xxl-center { + justify-content: center !important; + } + .justify-content-xxl-between { + justify-content: space-between !important; + } + .justify-content-xxl-around { + justify-content: space-around !important; + } + .justify-content-xxl-evenly { + justify-content: space-evenly !important; + } + .align-items-xxl-start { + align-items: flex-start !important; + } + .align-items-xxl-end { + align-items: flex-end !important; + } + .align-items-xxl-center { + align-items: center !important; + } + .align-items-xxl-baseline { + align-items: baseline !important; + } + .align-items-xxl-stretch { + align-items: stretch !important; + } + .align-content-xxl-start { + align-content: flex-start !important; + } + .align-content-xxl-end { + align-content: flex-end !important; + } + .align-content-xxl-center { + align-content: center !important; + } + .align-content-xxl-between { + align-content: space-between !important; + } + .align-content-xxl-around { + align-content: space-around !important; + } + .align-content-xxl-stretch { + align-content: stretch !important; + } + .align-self-xxl-auto { + align-self: auto !important; + } + .align-self-xxl-start { + align-self: flex-start !important; + } + .align-self-xxl-end { + align-self: flex-end !important; + } + .align-self-xxl-center { + align-self: center !important; + } + .align-self-xxl-baseline { + align-self: baseline !important; + } + .align-self-xxl-stretch { + align-self: stretch !important; + } + .order-xxl-first { + order: -1 !important; + } + .order-xxl-0 { + order: 0 !important; + } + .order-xxl-1 { + order: 1 !important; + } + .order-xxl-2 { + order: 2 !important; + } + .order-xxl-3 { + order: 3 !important; + } + .order-xxl-4 { + order: 4 !important; + } + .order-xxl-5 { + order: 5 !important; + } + .order-xxl-last { + order: 6 !important; + } + .m-xxl-0 { + margin: 0 !important; + } + .m-xxl-1 { + margin: 0.25rem !important; + } + .m-xxl-2 { + margin: 0.5rem !important; + } + .m-xxl-3 { + margin: 1rem !important; + } + .m-xxl-4 { + margin: 1.5rem !important; + } + .m-xxl-5 { + margin: 3rem !important; + } + .m-xxl-auto { + margin: auto !important; + } + .mx-xxl-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + .mx-xxl-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-xxl-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + .mx-xxl-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + .mx-xxl-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + .mx-xxl-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + .mx-xxl-auto { + margin-right: auto !important; + margin-left: auto !important; + } + .my-xxl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xxl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xxl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xxl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xxl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xxl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xxl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xxl-0 { + margin-top: 0 !important; + } + .mt-xxl-1 { + margin-top: 0.25rem !important; + } + .mt-xxl-2 { + margin-top: 0.5rem !important; + } + .mt-xxl-3 { + margin-top: 1rem !important; + } + .mt-xxl-4 { + margin-top: 1.5rem !important; + } + .mt-xxl-5 { + margin-top: 3rem !important; + } + .mt-xxl-auto { + margin-top: auto !important; + } + .me-xxl-0 { + margin-right: 0 !important; + } + .me-xxl-1 { + margin-right: 0.25rem !important; + } + .me-xxl-2 { + margin-right: 0.5rem !important; + } + .me-xxl-3 { + margin-right: 1rem !important; + } + .me-xxl-4 { + margin-right: 1.5rem !important; + } + .me-xxl-5 { + margin-right: 3rem !important; + } + .me-xxl-auto { + margin-right: auto !important; + } + .mb-xxl-0 { + margin-bottom: 0 !important; + } + .mb-xxl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xxl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xxl-3 { + margin-bottom: 1rem !important; + } + .mb-xxl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xxl-5 { + margin-bottom: 3rem !important; + } + .mb-xxl-auto { + margin-bottom: auto !important; + } + .ms-xxl-0 { + margin-left: 0 !important; + } + .ms-xxl-1 { + margin-left: 0.25rem !important; + } + .ms-xxl-2 { + margin-left: 0.5rem !important; + } + .ms-xxl-3 { + margin-left: 1rem !important; + } + .ms-xxl-4 { + margin-left: 1.5rem !important; + } + .ms-xxl-5 { + margin-left: 3rem !important; + } + .ms-xxl-auto { + margin-left: auto !important; + } + .p-xxl-0 { + padding: 0 !important; + } + .p-xxl-1 { + padding: 0.25rem !important; + } + .p-xxl-2 { + padding: 0.5rem !important; + } + .p-xxl-3 { + padding: 1rem !important; + } + .p-xxl-4 { + padding: 1.5rem !important; + } + .p-xxl-5 { + padding: 3rem !important; + } + .px-xxl-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + .px-xxl-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-xxl-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + .px-xxl-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + .px-xxl-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + .px-xxl-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + .py-xxl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xxl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xxl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xxl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xxl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xxl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xxl-0 { + padding-top: 0 !important; + } + .pt-xxl-1 { + padding-top: 0.25rem !important; + } + .pt-xxl-2 { + padding-top: 0.5rem !important; + } + .pt-xxl-3 { + padding-top: 1rem !important; + } + .pt-xxl-4 { + padding-top: 1.5rem !important; + } + .pt-xxl-5 { + padding-top: 3rem !important; + } + .pe-xxl-0 { + padding-right: 0 !important; + } + .pe-xxl-1 { + padding-right: 0.25rem !important; + } + .pe-xxl-2 { + padding-right: 0.5rem !important; + } + .pe-xxl-3 { + padding-right: 1rem !important; + } + .pe-xxl-4 { + padding-right: 1.5rem !important; + } + .pe-xxl-5 { + padding-right: 3rem !important; + } + .pb-xxl-0 { + padding-bottom: 0 !important; + } + .pb-xxl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xxl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xxl-3 { + padding-bottom: 1rem !important; + } + .pb-xxl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xxl-5 { + padding-bottom: 3rem !important; + } + .ps-xxl-0 { + padding-left: 0 !important; + } + .ps-xxl-1 { + padding-left: 0.25rem !important; + } + .ps-xxl-2 { + padding-left: 0.5rem !important; + } + .ps-xxl-3 { + padding-left: 1rem !important; + } + .ps-xxl-4 { + padding-left: 1.5rem !important; + } + .ps-xxl-5 { + padding-left: 3rem !important; + } +} +@media print { + .d-print-inline { + display: inline !important; + } + .d-print-inline-block { + display: inline-block !important; + } + .d-print-block { + display: block !important; + } + .d-print-grid { + display: grid !important; + } + .d-print-inline-grid { + display: inline-grid !important; + } + .d-print-table { + display: table !important; + } + .d-print-table-row { + display: table-row !important; + } + .d-print-table-cell { + display: table-cell !important; + } + .d-print-flex { + display: flex !important; + } + .d-print-inline-flex { + display: inline-flex !important; + } + .d-print-none { + display: none !important; + } +} + +/*# sourceMappingURL=bootstrap-grid.css.map */ \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map new file mode 100644 index 00000000..ce99ec19 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","bootstrap-grid.css","../../scss/mixins/_breakpoints.scss","../../scss/_variables.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"AACE;;;;EAAA;ACKA;;;;;;;ECHA,qBAAA;EACA,gBAAA;EACA,WAAA;EACA,6CAAA;EACA,4CAAA;EACA,kBAAA;EACA,iBAAA;ACUF;;AC4CI;EH5CE;IACE,gBIkee;EF9drB;AACF;ACsCI;EH5CE;IACE,gBIkee;EFzdrB;AACF;ACiCI;EH5CE;IACE,gBIkee;EFpdrB;AACF;AC4BI;EH5CE;IACE,iBIkee;EF/crB;AACF;ACuBI;EH5CE;IACE,iBIkee;EF1crB;AACF;AGzCA;EAEI,qBAAA;EAAA,yBAAA;EAAA,yBAAA;EAAA,yBAAA;EAAA,0BAAA;EAAA,2BAAA;AH+CJ;;AG1CE;ECNA,qBAAA;EACA,gBAAA;EACA,aAAA;EACA,eAAA;EAEA,yCAAA;EACA,6CAAA;EACA,4CAAA;AJmDF;AGjDI;ECGF,sBAAA;EAIA,cAAA;EACA,WAAA;EACA,eAAA;EACA,6CAAA;EACA,4CAAA;EACA,8BAAA;AJ8CF;;AICM;EACE,YAAA;AJER;;AICM;EApCJ,cAAA;EACA,WAAA;AJuCF;;AIzBE;EACE,cAAA;EACA,WAAA;AJ4BJ;;AI9BE;EACE,cAAA;EACA,UAAA;AJiCJ;;AInCE;EACE,cAAA;EACA,mBAAA;AJsCJ;;AIxCE;EACE,cAAA;EACA,UAAA;AJ2CJ;;AI7CE;EACE,cAAA;EACA,UAAA;AJgDJ;;AIlDE;EACE,cAAA;EACA,mBAAA;AJqDJ;;AItBM;EAhDJ,cAAA;EACA,WAAA;AJ0EF;;AIrBU;EAhEN,cAAA;EACA,kBAAA;AJyFJ;;AI1BU;EAhEN,cAAA;EACA,mBAAA;AJ8FJ;;AI/BU;EAhEN,cAAA;EACA,UAAA;AJmGJ;;AIpCU;EAhEN,cAAA;EACA,mBAAA;AJwGJ;;AIzCU;EAhEN,cAAA;EACA,mBAAA;AJ6GJ;;AI9CU;EAhEN,cAAA;EACA,UAAA;AJkHJ;;AInDU;EAhEN,cAAA;EACA,mBAAA;AJuHJ;;AIxDU;EAhEN,cAAA;EACA,mBAAA;AJ4HJ;;AI7DU;EAhEN,cAAA;EACA,UAAA;AJiIJ;;AIlEU;EAhEN,cAAA;EACA,mBAAA;AJsIJ;;AIvEU;EAhEN,cAAA;EACA,mBAAA;AJ2IJ;;AI5EU;EAhEN,cAAA;EACA,WAAA;AJgJJ;;AIzEY;EAxDV,wBAAA;AJqIF;;AI7EY;EAxDV,yBAAA;AJyIF;;AIjFY;EAxDV,gBAAA;AJ6IF;;AIrFY;EAxDV,yBAAA;AJiJF;;AIzFY;EAxDV,yBAAA;AJqJF;;AI7FY;EAxDV,gBAAA;AJyJF;;AIjGY;EAxDV,yBAAA;AJ6JF;;AIrGY;EAxDV,yBAAA;AJiKF;;AIzGY;EAxDV,gBAAA;AJqKF;;AI7GY;EAxDV,yBAAA;AJyKF;;AIjHY;EAxDV,yBAAA;AJ6KF;;AI1GQ;;EAEE,gBAAA;AJ6GV;;AI1GQ;;EAEE,gBAAA;AJ6GV;;AIpHQ;;EAEE,sBAAA;AJuHV;;AIpHQ;;EAEE,sBAAA;AJuHV;;AI9HQ;;EAEE,qBAAA;AJiIV;;AI9HQ;;EAEE,qBAAA;AJiIV;;AIxIQ;;EAEE,mBAAA;AJ2IV;;AIxIQ;;EAEE,mBAAA;AJ2IV;;AIlJQ;;EAEE,qBAAA;AJqJV;;AIlJQ;;EAEE,qBAAA;AJqJV;;AI5JQ;;EAEE,mBAAA;AJ+JV;;AI5JQ;;EAEE,mBAAA;AJ+JV;;ACzNI;EGUE;IACE,YAAA;EJmNN;EIhNI;IApCJ,cAAA;IACA,WAAA;EJuPA;EIzOA;IACE,cAAA;IACA,WAAA;EJ2OF;EI7OA;IACE,cAAA;IACA,UAAA;EJ+OF;EIjPA;IACE,cAAA;IACA,mBAAA;EJmPF;EIrPA;IACE,cAAA;IACA,UAAA;EJuPF;EIzPA;IACE,cAAA;IACA,UAAA;EJ2PF;EI7PA;IACE,cAAA;IACA,mBAAA;EJ+PF;EIhOI;IAhDJ,cAAA;IACA,WAAA;EJmRA;EI9NQ;IAhEN,cAAA;IACA,kBAAA;EJiSF;EIlOQ;IAhEN,cAAA;IACA,mBAAA;EJqSF;EItOQ;IAhEN,cAAA;IACA,UAAA;EJySF;EI1OQ;IAhEN,cAAA;IACA,mBAAA;EJ6SF;EI9OQ;IAhEN,cAAA;IACA,mBAAA;EJiTF;EIlPQ;IAhEN,cAAA;IACA,UAAA;EJqTF;EItPQ;IAhEN,cAAA;IACA,mBAAA;EJyTF;EI1PQ;IAhEN,cAAA;IACA,mBAAA;EJ6TF;EI9PQ;IAhEN,cAAA;IACA,UAAA;EJiUF;EIlQQ;IAhEN,cAAA;IACA,mBAAA;EJqUF;EItQQ;IAhEN,cAAA;IACA,mBAAA;EJyUF;EI1QQ;IAhEN,cAAA;IACA,WAAA;EJ6UF;EItQU;IAxDV,cAAA;EJiUA;EIzQU;IAxDV,wBAAA;EJoUA;EI5QU;IAxDV,yBAAA;EJuUA;EI/QU;IAxDV,gBAAA;EJ0UA;EIlRU;IAxDV,yBAAA;EJ6UA;EIrRU;IAxDV,yBAAA;EJgVA;EIxRU;IAxDV,gBAAA;EJmVA;EI3RU;IAxDV,yBAAA;EJsVA;EI9RU;IAxDV,yBAAA;EJyVA;EIjSU;IAxDV,gBAAA;EJ4VA;EIpSU;IAxDV,yBAAA;EJ+VA;EIvSU;IAxDV,yBAAA;EJkWA;EI/RM;;IAEE,gBAAA;EJiSR;EI9RM;;IAEE,gBAAA;EJgSR;EIvSM;;IAEE,sBAAA;EJySR;EItSM;;IAEE,sBAAA;EJwSR;EI/SM;;IAEE,qBAAA;EJiTR;EI9SM;;IAEE,qBAAA;EJgTR;EIvTM;;IAEE,mBAAA;EJyTR;EItTM;;IAEE,mBAAA;EJwTR;EI/TM;;IAEE,qBAAA;EJiUR;EI9TM;;IAEE,qBAAA;EJgUR;EIvUM;;IAEE,mBAAA;EJyUR;EItUM;;IAEE,mBAAA;EJwUR;AACF;ACnYI;EGUE;IACE,YAAA;EJ4XN;EIzXI;IApCJ,cAAA;IACA,WAAA;EJgaA;EIlZA;IACE,cAAA;IACA,WAAA;EJoZF;EItZA;IACE,cAAA;IACA,UAAA;EJwZF;EI1ZA;IACE,cAAA;IACA,mBAAA;EJ4ZF;EI9ZA;IACE,cAAA;IACA,UAAA;EJgaF;EIlaA;IACE,cAAA;IACA,UAAA;EJoaF;EItaA;IACE,cAAA;IACA,mBAAA;EJwaF;EIzYI;IAhDJ,cAAA;IACA,WAAA;EJ4bA;EIvYQ;IAhEN,cAAA;IACA,kBAAA;EJ0cF;EI3YQ;IAhEN,cAAA;IACA,mBAAA;EJ8cF;EI/YQ;IAhEN,cAAA;IACA,UAAA;EJkdF;EInZQ;IAhEN,cAAA;IACA,mBAAA;EJsdF;EIvZQ;IAhEN,cAAA;IACA,mBAAA;EJ0dF;EI3ZQ;IAhEN,cAAA;IACA,UAAA;EJ8dF;EI/ZQ;IAhEN,cAAA;IACA,mBAAA;EJkeF;EInaQ;IAhEN,cAAA;IACA,mBAAA;EJseF;EIvaQ;IAhEN,cAAA;IACA,UAAA;EJ0eF;EI3aQ;IAhEN,cAAA;IACA,mBAAA;EJ8eF;EI/aQ;IAhEN,cAAA;IACA,mBAAA;EJkfF;EInbQ;IAhEN,cAAA;IACA,WAAA;EJsfF;EI/aU;IAxDV,cAAA;EJ0eA;EIlbU;IAxDV,wBAAA;EJ6eA;EIrbU;IAxDV,yBAAA;EJgfA;EIxbU;IAxDV,gBAAA;EJmfA;EI3bU;IAxDV,yBAAA;EJsfA;EI9bU;IAxDV,yBAAA;EJyfA;EIjcU;IAxDV,gBAAA;EJ4fA;EIpcU;IAxDV,yBAAA;EJ+fA;EIvcU;IAxDV,yBAAA;EJkgBA;EI1cU;IAxDV,gBAAA;EJqgBA;EI7cU;IAxDV,yBAAA;EJwgBA;EIhdU;IAxDV,yBAAA;EJ2gBA;EIxcM;;IAEE,gBAAA;EJ0cR;EIvcM;;IAEE,gBAAA;EJycR;EIhdM;;IAEE,sBAAA;EJkdR;EI/cM;;IAEE,sBAAA;EJidR;EIxdM;;IAEE,qBAAA;EJ0dR;EIvdM;;IAEE,qBAAA;EJydR;EIheM;;IAEE,mBAAA;EJkeR;EI/dM;;IAEE,mBAAA;EJieR;EIxeM;;IAEE,qBAAA;EJ0eR;EIveM;;IAEE,qBAAA;EJyeR;EIhfM;;IAEE,mBAAA;EJkfR;EI/eM;;IAEE,mBAAA;EJifR;AACF;AC5iBI;EGUE;IACE,YAAA;EJqiBN;EIliBI;IApCJ,cAAA;IACA,WAAA;EJykBA;EI3jBA;IACE,cAAA;IACA,WAAA;EJ6jBF;EI/jBA;IACE,cAAA;IACA,UAAA;EJikBF;EInkBA;IACE,cAAA;IACA,mBAAA;EJqkBF;EIvkBA;IACE,cAAA;IACA,UAAA;EJykBF;EI3kBA;IACE,cAAA;IACA,UAAA;EJ6kBF;EI/kBA;IACE,cAAA;IACA,mBAAA;EJilBF;EIljBI;IAhDJ,cAAA;IACA,WAAA;EJqmBA;EIhjBQ;IAhEN,cAAA;IACA,kBAAA;EJmnBF;EIpjBQ;IAhEN,cAAA;IACA,mBAAA;EJunBF;EIxjBQ;IAhEN,cAAA;IACA,UAAA;EJ2nBF;EI5jBQ;IAhEN,cAAA;IACA,mBAAA;EJ+nBF;EIhkBQ;IAhEN,cAAA;IACA,mBAAA;EJmoBF;EIpkBQ;IAhEN,cAAA;IACA,UAAA;EJuoBF;EIxkBQ;IAhEN,cAAA;IACA,mBAAA;EJ2oBF;EI5kBQ;IAhEN,cAAA;IACA,mBAAA;EJ+oBF;EIhlBQ;IAhEN,cAAA;IACA,UAAA;EJmpBF;EIplBQ;IAhEN,cAAA;IACA,mBAAA;EJupBF;EIxlBQ;IAhEN,cAAA;IACA,mBAAA;EJ2pBF;EI5lBQ;IAhEN,cAAA;IACA,WAAA;EJ+pBF;EIxlBU;IAxDV,cAAA;EJmpBA;EI3lBU;IAxDV,wBAAA;EJspBA;EI9lBU;IAxDV,yBAAA;EJypBA;EIjmBU;IAxDV,gBAAA;EJ4pBA;EIpmBU;IAxDV,yBAAA;EJ+pBA;EIvmBU;IAxDV,yBAAA;EJkqBA;EI1mBU;IAxDV,gBAAA;EJqqBA;EI7mBU;IAxDV,yBAAA;EJwqBA;EIhnBU;IAxDV,yBAAA;EJ2qBA;EInnBU;IAxDV,gBAAA;EJ8qBA;EItnBU;IAxDV,yBAAA;EJirBA;EIznBU;IAxDV,yBAAA;EJorBA;EIjnBM;;IAEE,gBAAA;EJmnBR;EIhnBM;;IAEE,gBAAA;EJknBR;EIznBM;;IAEE,sBAAA;EJ2nBR;EIxnBM;;IAEE,sBAAA;EJ0nBR;EIjoBM;;IAEE,qBAAA;EJmoBR;EIhoBM;;IAEE,qBAAA;EJkoBR;EIzoBM;;IAEE,mBAAA;EJ2oBR;EIxoBM;;IAEE,mBAAA;EJ0oBR;EIjpBM;;IAEE,qBAAA;EJmpBR;EIhpBM;;IAEE,qBAAA;EJkpBR;EIzpBM;;IAEE,mBAAA;EJ2pBR;EIxpBM;;IAEE,mBAAA;EJ0pBR;AACF;ACrtBI;EGUE;IACE,YAAA;EJ8sBN;EI3sBI;IApCJ,cAAA;IACA,WAAA;EJkvBA;EIpuBA;IACE,cAAA;IACA,WAAA;EJsuBF;EIxuBA;IACE,cAAA;IACA,UAAA;EJ0uBF;EI5uBA;IACE,cAAA;IACA,mBAAA;EJ8uBF;EIhvBA;IACE,cAAA;IACA,UAAA;EJkvBF;EIpvBA;IACE,cAAA;IACA,UAAA;EJsvBF;EIxvBA;IACE,cAAA;IACA,mBAAA;EJ0vBF;EI3tBI;IAhDJ,cAAA;IACA,WAAA;EJ8wBA;EIztBQ;IAhEN,cAAA;IACA,kBAAA;EJ4xBF;EI7tBQ;IAhEN,cAAA;IACA,mBAAA;EJgyBF;EIjuBQ;IAhEN,cAAA;IACA,UAAA;EJoyBF;EIruBQ;IAhEN,cAAA;IACA,mBAAA;EJwyBF;EIzuBQ;IAhEN,cAAA;IACA,mBAAA;EJ4yBF;EI7uBQ;IAhEN,cAAA;IACA,UAAA;EJgzBF;EIjvBQ;IAhEN,cAAA;IACA,mBAAA;EJozBF;EIrvBQ;IAhEN,cAAA;IACA,mBAAA;EJwzBF;EIzvBQ;IAhEN,cAAA;IACA,UAAA;EJ4zBF;EI7vBQ;IAhEN,cAAA;IACA,mBAAA;EJg0BF;EIjwBQ;IAhEN,cAAA;IACA,mBAAA;EJo0BF;EIrwBQ;IAhEN,cAAA;IACA,WAAA;EJw0BF;EIjwBU;IAxDV,cAAA;EJ4zBA;EIpwBU;IAxDV,wBAAA;EJ+zBA;EIvwBU;IAxDV,yBAAA;EJk0BA;EI1wBU;IAxDV,gBAAA;EJq0BA;EI7wBU;IAxDV,yBAAA;EJw0BA;EIhxBU;IAxDV,yBAAA;EJ20BA;EInxBU;IAxDV,gBAAA;EJ80BA;EItxBU;IAxDV,yBAAA;EJi1BA;EIzxBU;IAxDV,yBAAA;EJo1BA;EI5xBU;IAxDV,gBAAA;EJu1BA;EI/xBU;IAxDV,yBAAA;EJ01BA;EIlyBU;IAxDV,yBAAA;EJ61BA;EI1xBM;;IAEE,gBAAA;EJ4xBR;EIzxBM;;IAEE,gBAAA;EJ2xBR;EIlyBM;;IAEE,sBAAA;EJoyBR;EIjyBM;;IAEE,sBAAA;EJmyBR;EI1yBM;;IAEE,qBAAA;EJ4yBR;EIzyBM;;IAEE,qBAAA;EJ2yBR;EIlzBM;;IAEE,mBAAA;EJozBR;EIjzBM;;IAEE,mBAAA;EJmzBR;EI1zBM;;IAEE,qBAAA;EJ4zBR;EIzzBM;;IAEE,qBAAA;EJ2zBR;EIl0BM;;IAEE,mBAAA;EJo0BR;EIj0BM;;IAEE,mBAAA;EJm0BR;AACF;AC93BI;EGUE;IACE,YAAA;EJu3BN;EIp3BI;IApCJ,cAAA;IACA,WAAA;EJ25BA;EI74BA;IACE,cAAA;IACA,WAAA;EJ+4BF;EIj5BA;IACE,cAAA;IACA,UAAA;EJm5BF;EIr5BA;IACE,cAAA;IACA,mBAAA;EJu5BF;EIz5BA;IACE,cAAA;IACA,UAAA;EJ25BF;EI75BA;IACE,cAAA;IACA,UAAA;EJ+5BF;EIj6BA;IACE,cAAA;IACA,mBAAA;EJm6BF;EIp4BI;IAhDJ,cAAA;IACA,WAAA;EJu7BA;EIl4BQ;IAhEN,cAAA;IACA,kBAAA;EJq8BF;EIt4BQ;IAhEN,cAAA;IACA,mBAAA;EJy8BF;EI14BQ;IAhEN,cAAA;IACA,UAAA;EJ68BF;EI94BQ;IAhEN,cAAA;IACA,mBAAA;EJi9BF;EIl5BQ;IAhEN,cAAA;IACA,mBAAA;EJq9BF;EIt5BQ;IAhEN,cAAA;IACA,UAAA;EJy9BF;EI15BQ;IAhEN,cAAA;IACA,mBAAA;EJ69BF;EI95BQ;IAhEN,cAAA;IACA,mBAAA;EJi+BF;EIl6BQ;IAhEN,cAAA;IACA,UAAA;EJq+BF;EIt6BQ;IAhEN,cAAA;IACA,mBAAA;EJy+BF;EI16BQ;IAhEN,cAAA;IACA,mBAAA;EJ6+BF;EI96BQ;IAhEN,cAAA;IACA,WAAA;EJi/BF;EI16BU;IAxDV,cAAA;EJq+BA;EI76BU;IAxDV,wBAAA;EJw+BA;EIh7BU;IAxDV,yBAAA;EJ2+BA;EIn7BU;IAxDV,gBAAA;EJ8+BA;EIt7BU;IAxDV,yBAAA;EJi/BA;EIz7BU;IAxDV,yBAAA;EJo/BA;EI57BU;IAxDV,gBAAA;EJu/BA;EI/7BU;IAxDV,yBAAA;EJ0/BA;EIl8BU;IAxDV,yBAAA;EJ6/BA;EIr8BU;IAxDV,gBAAA;EJggCA;EIx8BU;IAxDV,yBAAA;EJmgCA;EI38BU;IAxDV,yBAAA;EJsgCA;EIn8BM;;IAEE,gBAAA;EJq8BR;EIl8BM;;IAEE,gBAAA;EJo8BR;EI38BM;;IAEE,sBAAA;EJ68BR;EI18BM;;IAEE,sBAAA;EJ48BR;EIn9BM;;IAEE,qBAAA;EJq9BR;EIl9BM;;IAEE,qBAAA;EJo9BR;EI39BM;;IAEE,mBAAA;EJ69BR;EI19BM;;IAEE,mBAAA;EJ49BR;EIn+BM;;IAEE,qBAAA;EJq+BR;EIl+BM;;IAEE,qBAAA;EJo+BR;EI3+BM;;IAEE,mBAAA;EJ6+BR;EI1+BM;;IAEE,mBAAA;EJ4+BR;AACF;AKpiCQ;EAOI,0BAAA;ALgiCZ;;AKviCQ;EAOI,gCAAA;ALoiCZ;;AK3iCQ;EAOI,yBAAA;ALwiCZ;;AK/iCQ;EAOI,wBAAA;AL4iCZ;;AKnjCQ;EAOI,+BAAA;ALgjCZ;;AKvjCQ;EAOI,yBAAA;ALojCZ;;AK3jCQ;EAOI,6BAAA;ALwjCZ;;AK/jCQ;EAOI,8BAAA;AL4jCZ;;AKnkCQ;EAOI,wBAAA;ALgkCZ;;AKvkCQ;EAOI,+BAAA;ALokCZ;;AK3kCQ;EAOI,wBAAA;ALwkCZ;;AK/kCQ;EAOI,yBAAA;AL4kCZ;;AKnlCQ;EAOI,8BAAA;ALglCZ;;AKvlCQ;EAOI,iCAAA;ALolCZ;;AK3lCQ;EAOI,sCAAA;ALwlCZ;;AK/lCQ;EAOI,yCAAA;AL4lCZ;;AKnmCQ;EAOI,uBAAA;ALgmCZ;;AKvmCQ;EAOI,uBAAA;ALomCZ;;AK3mCQ;EAOI,yBAAA;ALwmCZ;;AK/mCQ;EAOI,yBAAA;AL4mCZ;;AKnnCQ;EAOI,0BAAA;ALgnCZ;;AKvnCQ;EAOI,4BAAA;ALonCZ;;AK3nCQ;EAOI,kCAAA;ALwnCZ;;AK/nCQ;EAOI,sCAAA;AL4nCZ;;AKnoCQ;EAOI,oCAAA;ALgoCZ;;AKvoCQ;EAOI,kCAAA;ALooCZ;;AK3oCQ;EAOI,yCAAA;ALwoCZ;;AK/oCQ;EAOI,wCAAA;AL4oCZ;;AKnpCQ;EAOI,wCAAA;ALgpCZ;;AKvpCQ;EAOI,kCAAA;ALopCZ;;AK3pCQ;EAOI,gCAAA;ALwpCZ;;AK/pCQ;EAOI,8BAAA;AL4pCZ;;AKnqCQ;EAOI,gCAAA;ALgqCZ;;AKvqCQ;EAOI,+BAAA;ALoqCZ;;AK3qCQ;EAOI,oCAAA;ALwqCZ;;AK/qCQ;EAOI,kCAAA;AL4qCZ;;AKnrCQ;EAOI,gCAAA;ALgrCZ;;AKvrCQ;EAOI,uCAAA;ALorCZ;;AK3rCQ;EAOI,sCAAA;ALwrCZ;;AK/rCQ;EAOI,iCAAA;AL4rCZ;;AKnsCQ;EAOI,2BAAA;ALgsCZ;;AKvsCQ;EAOI,iCAAA;ALosCZ;;AK3sCQ;EAOI,+BAAA;ALwsCZ;;AK/sCQ;EAOI,6BAAA;AL4sCZ;;AKntCQ;EAOI,+BAAA;ALgtCZ;;AKvtCQ;EAOI,8BAAA;ALotCZ;;AK3tCQ;EAOI,oBAAA;ALwtCZ;;AK/tCQ;EAOI,mBAAA;AL4tCZ;;AKnuCQ;EAOI,mBAAA;ALguCZ;;AKvuCQ;EAOI,mBAAA;ALouCZ;;AK3uCQ;EAOI,mBAAA;ALwuCZ;;AK/uCQ;EAOI,mBAAA;AL4uCZ;;AKnvCQ;EAOI,mBAAA;ALgvCZ;;AKvvCQ;EAOI,mBAAA;ALovCZ;;AK3vCQ;EAOI,oBAAA;ALwvCZ;;AK/vCQ;EAOI,0BAAA;AL4vCZ;;AKnwCQ;EAOI,yBAAA;ALgwCZ;;AKvwCQ;EAOI,uBAAA;ALowCZ;;AK3wCQ;EAOI,yBAAA;ALwwCZ;;AK/wCQ;EAOI,uBAAA;AL4wCZ;;AKnxCQ;EAOI,uBAAA;ALgxCZ;;AKvxCQ;EAOI,0BAAA;EAAA,yBAAA;ALqxCZ;;AK5xCQ;EAOI,gCAAA;EAAA,+BAAA;AL0xCZ;;AKjyCQ;EAOI,+BAAA;EAAA,8BAAA;AL+xCZ;;AKtyCQ;EAOI,6BAAA;EAAA,4BAAA;ALoyCZ;;AK3yCQ;EAOI,+BAAA;EAAA,8BAAA;ALyyCZ;;AKhzCQ;EAOI,6BAAA;EAAA,4BAAA;AL8yCZ;;AKrzCQ;EAOI,6BAAA;EAAA,4BAAA;ALmzCZ;;AK1zCQ;EAOI,wBAAA;EAAA,2BAAA;ALwzCZ;;AK/zCQ;EAOI,8BAAA;EAAA,iCAAA;AL6zCZ;;AKp0CQ;EAOI,6BAAA;EAAA,gCAAA;ALk0CZ;;AKz0CQ;EAOI,2BAAA;EAAA,8BAAA;ALu0CZ;;AK90CQ;EAOI,6BAAA;EAAA,gCAAA;AL40CZ;;AKn1CQ;EAOI,2BAAA;EAAA,8BAAA;ALi1CZ;;AKx1CQ;EAOI,2BAAA;EAAA,8BAAA;ALs1CZ;;AK71CQ;EAOI,wBAAA;AL01CZ;;AKj2CQ;EAOI,8BAAA;AL81CZ;;AKr2CQ;EAOI,6BAAA;ALk2CZ;;AKz2CQ;EAOI,2BAAA;ALs2CZ;;AK72CQ;EAOI,6BAAA;AL02CZ;;AKj3CQ;EAOI,2BAAA;AL82CZ;;AKr3CQ;EAOI,2BAAA;ALk3CZ;;AKz3CQ;EAOI,0BAAA;ALs3CZ;;AK73CQ;EAOI,gCAAA;AL03CZ;;AKj4CQ;EAOI,+BAAA;AL83CZ;;AKr4CQ;EAOI,6BAAA;ALk4CZ;;AKz4CQ;EAOI,+BAAA;ALs4CZ;;AK74CQ;EAOI,6BAAA;AL04CZ;;AKj5CQ;EAOI,6BAAA;AL84CZ;;AKr5CQ;EAOI,2BAAA;ALk5CZ;;AKz5CQ;EAOI,iCAAA;ALs5CZ;;AK75CQ;EAOI,gCAAA;AL05CZ;;AKj6CQ;EAOI,8BAAA;AL85CZ;;AKr6CQ;EAOI,gCAAA;ALk6CZ;;AKz6CQ;EAOI,8BAAA;ALs6CZ;;AK76CQ;EAOI,8BAAA;AL06CZ;;AKj7CQ;EAOI,yBAAA;AL86CZ;;AKr7CQ;EAOI,+BAAA;ALk7CZ;;AKz7CQ;EAOI,8BAAA;ALs7CZ;;AK77CQ;EAOI,4BAAA;AL07CZ;;AKj8CQ;EAOI,8BAAA;AL87CZ;;AKr8CQ;EAOI,4BAAA;ALk8CZ;;AKz8CQ;EAOI,4BAAA;ALs8CZ;;AK78CQ;EAOI,qBAAA;AL08CZ;;AKj9CQ;EAOI,2BAAA;AL88CZ;;AKr9CQ;EAOI,0BAAA;ALk9CZ;;AKz9CQ;EAOI,wBAAA;ALs9CZ;;AK79CQ;EAOI,0BAAA;AL09CZ;;AKj+CQ;EAOI,wBAAA;AL89CZ;;AKr+CQ;EAOI,2BAAA;EAAA,0BAAA;ALm+CZ;;AK1+CQ;EAOI,iCAAA;EAAA,gCAAA;ALw+CZ;;AK/+CQ;EAOI,gCAAA;EAAA,+BAAA;AL6+CZ;;AKp/CQ;EAOI,8BAAA;EAAA,6BAAA;ALk/CZ;;AKz/CQ;EAOI,gCAAA;EAAA,+BAAA;ALu/CZ;;AK9/CQ;EAOI,8BAAA;EAAA,6BAAA;AL4/CZ;;AKngDQ;EAOI,yBAAA;EAAA,4BAAA;ALigDZ;;AKxgDQ;EAOI,+BAAA;EAAA,kCAAA;ALsgDZ;;AK7gDQ;EAOI,8BAAA;EAAA,iCAAA;AL2gDZ;;AKlhDQ;EAOI,4BAAA;EAAA,+BAAA;ALghDZ;;AKvhDQ;EAOI,8BAAA;EAAA,iCAAA;ALqhDZ;;AK5hDQ;EAOI,4BAAA;EAAA,+BAAA;AL0hDZ;;AKjiDQ;EAOI,yBAAA;AL8hDZ;;AKriDQ;EAOI,+BAAA;ALkiDZ;;AKziDQ;EAOI,8BAAA;ALsiDZ;;AK7iDQ;EAOI,4BAAA;AL0iDZ;;AKjjDQ;EAOI,8BAAA;AL8iDZ;;AKrjDQ;EAOI,4BAAA;ALkjDZ;;AKzjDQ;EAOI,2BAAA;ALsjDZ;;AK7jDQ;EAOI,iCAAA;AL0jDZ;;AKjkDQ;EAOI,gCAAA;AL8jDZ;;AKrkDQ;EAOI,8BAAA;ALkkDZ;;AKzkDQ;EAOI,gCAAA;ALskDZ;;AK7kDQ;EAOI,8BAAA;AL0kDZ;;AKjlDQ;EAOI,4BAAA;AL8kDZ;;AKrlDQ;EAOI,kCAAA;ALklDZ;;AKzlDQ;EAOI,iCAAA;ALslDZ;;AK7lDQ;EAOI,+BAAA;AL0lDZ;;AKjmDQ;EAOI,iCAAA;AL8lDZ;;AKrmDQ;EAOI,+BAAA;ALkmDZ;;AKzmDQ;EAOI,0BAAA;ALsmDZ;;AK7mDQ;EAOI,gCAAA;AL0mDZ;;AKjnDQ;EAOI,+BAAA;AL8mDZ;;AKrnDQ;EAOI,6BAAA;ALknDZ;;AKznDQ;EAOI,+BAAA;ALsnDZ;;AK7nDQ;EAOI,6BAAA;AL0nDZ;;ACpoDI;EIGI;IAOI,0BAAA;EL+nDV;EKtoDM;IAOI,gCAAA;ELkoDV;EKzoDM;IAOI,yBAAA;ELqoDV;EK5oDM;IAOI,wBAAA;ELwoDV;EK/oDM;IAOI,+BAAA;EL2oDV;EKlpDM;IAOI,yBAAA;EL8oDV;EKrpDM;IAOI,6BAAA;ELipDV;EKxpDM;IAOI,8BAAA;ELopDV;EK3pDM;IAOI,wBAAA;ELupDV;EK9pDM;IAOI,+BAAA;EL0pDV;EKjqDM;IAOI,wBAAA;EL6pDV;EKpqDM;IAOI,yBAAA;ELgqDV;EKvqDM;IAOI,8BAAA;ELmqDV;EK1qDM;IAOI,iCAAA;ELsqDV;EK7qDM;IAOI,sCAAA;ELyqDV;EKhrDM;IAOI,yCAAA;EL4qDV;EKnrDM;IAOI,uBAAA;EL+qDV;EKtrDM;IAOI,uBAAA;ELkrDV;EKzrDM;IAOI,yBAAA;ELqrDV;EK5rDM;IAOI,yBAAA;ELwrDV;EK/rDM;IAOI,0BAAA;EL2rDV;EKlsDM;IAOI,4BAAA;EL8rDV;EKrsDM;IAOI,kCAAA;ELisDV;EKxsDM;IAOI,sCAAA;ELosDV;EK3sDM;IAOI,oCAAA;ELusDV;EK9sDM;IAOI,kCAAA;EL0sDV;EKjtDM;IAOI,yCAAA;EL6sDV;EKptDM;IAOI,wCAAA;ELgtDV;EKvtDM;IAOI,wCAAA;ELmtDV;EK1tDM;IAOI,kCAAA;ELstDV;EK7tDM;IAOI,gCAAA;ELytDV;EKhuDM;IAOI,8BAAA;EL4tDV;EKnuDM;IAOI,gCAAA;EL+tDV;EKtuDM;IAOI,+BAAA;ELkuDV;EKzuDM;IAOI,oCAAA;ELquDV;EK5uDM;IAOI,kCAAA;ELwuDV;EK/uDM;IAOI,gCAAA;EL2uDV;EKlvDM;IAOI,uCAAA;EL8uDV;EKrvDM;IAOI,sCAAA;ELivDV;EKxvDM;IAOI,iCAAA;ELovDV;EK3vDM;IAOI,2BAAA;ELuvDV;EK9vDM;IAOI,iCAAA;EL0vDV;EKjwDM;IAOI,+BAAA;EL6vDV;EKpwDM;IAOI,6BAAA;ELgwDV;EKvwDM;IAOI,+BAAA;ELmwDV;EK1wDM;IAOI,8BAAA;ELswDV;EK7wDM;IAOI,oBAAA;ELywDV;EKhxDM;IAOI,mBAAA;EL4wDV;EKnxDM;IAOI,mBAAA;EL+wDV;EKtxDM;IAOI,mBAAA;ELkxDV;EKzxDM;IAOI,mBAAA;ELqxDV;EK5xDM;IAOI,mBAAA;ELwxDV;EK/xDM;IAOI,mBAAA;EL2xDV;EKlyDM;IAOI,mBAAA;EL8xDV;EKryDM;IAOI,oBAAA;ELiyDV;EKxyDM;IAOI,0BAAA;ELoyDV;EK3yDM;IAOI,yBAAA;ELuyDV;EK9yDM;IAOI,uBAAA;EL0yDV;EKjzDM;IAOI,yBAAA;EL6yDV;EKpzDM;IAOI,uBAAA;ELgzDV;EKvzDM;IAOI,uBAAA;ELmzDV;EK1zDM;IAOI,0BAAA;IAAA,yBAAA;ELuzDV;EK9zDM;IAOI,gCAAA;IAAA,+BAAA;EL2zDV;EKl0DM;IAOI,+BAAA;IAAA,8BAAA;EL+zDV;EKt0DM;IAOI,6BAAA;IAAA,4BAAA;ELm0DV;EK10DM;IAOI,+BAAA;IAAA,8BAAA;ELu0DV;EK90DM;IAOI,6BAAA;IAAA,4BAAA;EL20DV;EKl1DM;IAOI,6BAAA;IAAA,4BAAA;EL+0DV;EKt1DM;IAOI,wBAAA;IAAA,2BAAA;ELm1DV;EK11DM;IAOI,8BAAA;IAAA,iCAAA;ELu1DV;EK91DM;IAOI,6BAAA;IAAA,gCAAA;EL21DV;EKl2DM;IAOI,2BAAA;IAAA,8BAAA;EL+1DV;EKt2DM;IAOI,6BAAA;IAAA,gCAAA;ELm2DV;EK12DM;IAOI,2BAAA;IAAA,8BAAA;ELu2DV;EK92DM;IAOI,2BAAA;IAAA,8BAAA;EL22DV;EKl3DM;IAOI,wBAAA;EL82DV;EKr3DM;IAOI,8BAAA;ELi3DV;EKx3DM;IAOI,6BAAA;ELo3DV;EK33DM;IAOI,2BAAA;ELu3DV;EK93DM;IAOI,6BAAA;EL03DV;EKj4DM;IAOI,2BAAA;EL63DV;EKp4DM;IAOI,2BAAA;ELg4DV;EKv4DM;IAOI,0BAAA;ELm4DV;EK14DM;IAOI,gCAAA;ELs4DV;EK74DM;IAOI,+BAAA;ELy4DV;EKh5DM;IAOI,6BAAA;EL44DV;EKn5DM;IAOI,+BAAA;EL+4DV;EKt5DM;IAOI,6BAAA;ELk5DV;EKz5DM;IAOI,6BAAA;ELq5DV;EK55DM;IAOI,2BAAA;ELw5DV;EK/5DM;IAOI,iCAAA;EL25DV;EKl6DM;IAOI,gCAAA;EL85DV;EKr6DM;IAOI,8BAAA;ELi6DV;EKx6DM;IAOI,gCAAA;ELo6DV;EK36DM;IAOI,8BAAA;ELu6DV;EK96DM;IAOI,8BAAA;EL06DV;EKj7DM;IAOI,yBAAA;EL66DV;EKp7DM;IAOI,+BAAA;ELg7DV;EKv7DM;IAOI,8BAAA;ELm7DV;EK17DM;IAOI,4BAAA;ELs7DV;EK77DM;IAOI,8BAAA;ELy7DV;EKh8DM;IAOI,4BAAA;EL47DV;EKn8DM;IAOI,4BAAA;EL+7DV;EKt8DM;IAOI,qBAAA;ELk8DV;EKz8DM;IAOI,2BAAA;ELq8DV;EK58DM;IAOI,0BAAA;ELw8DV;EK/8DM;IAOI,wBAAA;EL28DV;EKl9DM;IAOI,0BAAA;EL88DV;EKr9DM;IAOI,wBAAA;ELi9DV;EKx9DM;IAOI,2BAAA;IAAA,0BAAA;ELq9DV;EK59DM;IAOI,iCAAA;IAAA,gCAAA;ELy9DV;EKh+DM;IAOI,gCAAA;IAAA,+BAAA;EL69DV;EKp+DM;IAOI,8BAAA;IAAA,6BAAA;ELi+DV;EKx+DM;IAOI,gCAAA;IAAA,+BAAA;ELq+DV;EK5+DM;IAOI,8BAAA;IAAA,6BAAA;ELy+DV;EKh/DM;IAOI,yBAAA;IAAA,4BAAA;EL6+DV;EKp/DM;IAOI,+BAAA;IAAA,kCAAA;ELi/DV;EKx/DM;IAOI,8BAAA;IAAA,iCAAA;ELq/DV;EK5/DM;IAOI,4BAAA;IAAA,+BAAA;ELy/DV;EKhgEM;IAOI,8BAAA;IAAA,iCAAA;EL6/DV;EKpgEM;IAOI,4BAAA;IAAA,+BAAA;ELigEV;EKxgEM;IAOI,yBAAA;ELogEV;EK3gEM;IAOI,+BAAA;ELugEV;EK9gEM;IAOI,8BAAA;EL0gEV;EKjhEM;IAOI,4BAAA;EL6gEV;EKphEM;IAOI,8BAAA;ELghEV;EKvhEM;IAOI,4BAAA;ELmhEV;EK1hEM;IAOI,2BAAA;ELshEV;EK7hEM;IAOI,iCAAA;ELyhEV;EKhiEM;IAOI,gCAAA;EL4hEV;EKniEM;IAOI,8BAAA;EL+hEV;EKtiEM;IAOI,gCAAA;ELkiEV;EKziEM;IAOI,8BAAA;ELqiEV;EK5iEM;IAOI,4BAAA;ELwiEV;EK/iEM;IAOI,kCAAA;EL2iEV;EKljEM;IAOI,iCAAA;EL8iEV;EKrjEM;IAOI,+BAAA;ELijEV;EKxjEM;IAOI,iCAAA;ELojEV;EK3jEM;IAOI,+BAAA;ELujEV;EK9jEM;IAOI,0BAAA;EL0jEV;EKjkEM;IAOI,gCAAA;EL6jEV;EKpkEM;IAOI,+BAAA;ELgkEV;EKvkEM;IAOI,6BAAA;ELmkEV;EK1kEM;IAOI,+BAAA;ELskEV;EK7kEM;IAOI,6BAAA;ELykEV;AACF;ACplEI;EIGI;IAOI,0BAAA;EL8kEV;EKrlEM;IAOI,gCAAA;ELilEV;EKxlEM;IAOI,yBAAA;ELolEV;EK3lEM;IAOI,wBAAA;ELulEV;EK9lEM;IAOI,+BAAA;EL0lEV;EKjmEM;IAOI,yBAAA;EL6lEV;EKpmEM;IAOI,6BAAA;ELgmEV;EKvmEM;IAOI,8BAAA;ELmmEV;EK1mEM;IAOI,wBAAA;ELsmEV;EK7mEM;IAOI,+BAAA;ELymEV;EKhnEM;IAOI,wBAAA;EL4mEV;EKnnEM;IAOI,yBAAA;EL+mEV;EKtnEM;IAOI,8BAAA;ELknEV;EKznEM;IAOI,iCAAA;ELqnEV;EK5nEM;IAOI,sCAAA;ELwnEV;EK/nEM;IAOI,yCAAA;EL2nEV;EKloEM;IAOI,uBAAA;EL8nEV;EKroEM;IAOI,uBAAA;ELioEV;EKxoEM;IAOI,yBAAA;ELooEV;EK3oEM;IAOI,yBAAA;ELuoEV;EK9oEM;IAOI,0BAAA;EL0oEV;EKjpEM;IAOI,4BAAA;EL6oEV;EKppEM;IAOI,kCAAA;ELgpEV;EKvpEM;IAOI,sCAAA;ELmpEV;EK1pEM;IAOI,oCAAA;ELspEV;EK7pEM;IAOI,kCAAA;ELypEV;EKhqEM;IAOI,yCAAA;EL4pEV;EKnqEM;IAOI,wCAAA;EL+pEV;EKtqEM;IAOI,wCAAA;ELkqEV;EKzqEM;IAOI,kCAAA;ELqqEV;EK5qEM;IAOI,gCAAA;ELwqEV;EK/qEM;IAOI,8BAAA;EL2qEV;EKlrEM;IAOI,gCAAA;EL8qEV;EKrrEM;IAOI,+BAAA;ELirEV;EKxrEM;IAOI,oCAAA;ELorEV;EK3rEM;IAOI,kCAAA;ELurEV;EK9rEM;IAOI,gCAAA;EL0rEV;EKjsEM;IAOI,uCAAA;EL6rEV;EKpsEM;IAOI,sCAAA;ELgsEV;EKvsEM;IAOI,iCAAA;ELmsEV;EK1sEM;IAOI,2BAAA;ELssEV;EK7sEM;IAOI,iCAAA;ELysEV;EKhtEM;IAOI,+BAAA;EL4sEV;EKntEM;IAOI,6BAAA;EL+sEV;EKttEM;IAOI,+BAAA;ELktEV;EKztEM;IAOI,8BAAA;ELqtEV;EK5tEM;IAOI,oBAAA;ELwtEV;EK/tEM;IAOI,mBAAA;EL2tEV;EKluEM;IAOI,mBAAA;EL8tEV;EKruEM;IAOI,mBAAA;ELiuEV;EKxuEM;IAOI,mBAAA;ELouEV;EK3uEM;IAOI,mBAAA;ELuuEV;EK9uEM;IAOI,mBAAA;EL0uEV;EKjvEM;IAOI,mBAAA;EL6uEV;EKpvEM;IAOI,oBAAA;ELgvEV;EKvvEM;IAOI,0BAAA;ELmvEV;EK1vEM;IAOI,yBAAA;ELsvEV;EK7vEM;IAOI,uBAAA;ELyvEV;EKhwEM;IAOI,yBAAA;EL4vEV;EKnwEM;IAOI,uBAAA;EL+vEV;EKtwEM;IAOI,uBAAA;ELkwEV;EKzwEM;IAOI,0BAAA;IAAA,yBAAA;ELswEV;EK7wEM;IAOI,gCAAA;IAAA,+BAAA;EL0wEV;EKjxEM;IAOI,+BAAA;IAAA,8BAAA;EL8wEV;EKrxEM;IAOI,6BAAA;IAAA,4BAAA;ELkxEV;EKzxEM;IAOI,+BAAA;IAAA,8BAAA;ELsxEV;EK7xEM;IAOI,6BAAA;IAAA,4BAAA;EL0xEV;EKjyEM;IAOI,6BAAA;IAAA,4BAAA;EL8xEV;EKryEM;IAOI,wBAAA;IAAA,2BAAA;ELkyEV;EKzyEM;IAOI,8BAAA;IAAA,iCAAA;ELsyEV;EK7yEM;IAOI,6BAAA;IAAA,gCAAA;EL0yEV;EKjzEM;IAOI,2BAAA;IAAA,8BAAA;EL8yEV;EKrzEM;IAOI,6BAAA;IAAA,gCAAA;ELkzEV;EKzzEM;IAOI,2BAAA;IAAA,8BAAA;ELszEV;EK7zEM;IAOI,2BAAA;IAAA,8BAAA;EL0zEV;EKj0EM;IAOI,wBAAA;EL6zEV;EKp0EM;IAOI,8BAAA;ELg0EV;EKv0EM;IAOI,6BAAA;ELm0EV;EK10EM;IAOI,2BAAA;ELs0EV;EK70EM;IAOI,6BAAA;ELy0EV;EKh1EM;IAOI,2BAAA;EL40EV;EKn1EM;IAOI,2BAAA;EL+0EV;EKt1EM;IAOI,0BAAA;ELk1EV;EKz1EM;IAOI,gCAAA;ELq1EV;EK51EM;IAOI,+BAAA;ELw1EV;EK/1EM;IAOI,6BAAA;EL21EV;EKl2EM;IAOI,+BAAA;EL81EV;EKr2EM;IAOI,6BAAA;ELi2EV;EKx2EM;IAOI,6BAAA;ELo2EV;EK32EM;IAOI,2BAAA;ELu2EV;EK92EM;IAOI,iCAAA;EL02EV;EKj3EM;IAOI,gCAAA;EL62EV;EKp3EM;IAOI,8BAAA;ELg3EV;EKv3EM;IAOI,gCAAA;ELm3EV;EK13EM;IAOI,8BAAA;ELs3EV;EK73EM;IAOI,8BAAA;ELy3EV;EKh4EM;IAOI,yBAAA;EL43EV;EKn4EM;IAOI,+BAAA;EL+3EV;EKt4EM;IAOI,8BAAA;ELk4EV;EKz4EM;IAOI,4BAAA;ELq4EV;EK54EM;IAOI,8BAAA;ELw4EV;EK/4EM;IAOI,4BAAA;EL24EV;EKl5EM;IAOI,4BAAA;EL84EV;EKr5EM;IAOI,qBAAA;ELi5EV;EKx5EM;IAOI,2BAAA;ELo5EV;EK35EM;IAOI,0BAAA;ELu5EV;EK95EM;IAOI,wBAAA;EL05EV;EKj6EM;IAOI,0BAAA;EL65EV;EKp6EM;IAOI,wBAAA;ELg6EV;EKv6EM;IAOI,2BAAA;IAAA,0BAAA;ELo6EV;EK36EM;IAOI,iCAAA;IAAA,gCAAA;ELw6EV;EK/6EM;IAOI,gCAAA;IAAA,+BAAA;EL46EV;EKn7EM;IAOI,8BAAA;IAAA,6BAAA;ELg7EV;EKv7EM;IAOI,gCAAA;IAAA,+BAAA;ELo7EV;EK37EM;IAOI,8BAAA;IAAA,6BAAA;ELw7EV;EK/7EM;IAOI,yBAAA;IAAA,4BAAA;EL47EV;EKn8EM;IAOI,+BAAA;IAAA,kCAAA;ELg8EV;EKv8EM;IAOI,8BAAA;IAAA,iCAAA;ELo8EV;EK38EM;IAOI,4BAAA;IAAA,+BAAA;ELw8EV;EK/8EM;IAOI,8BAAA;IAAA,iCAAA;EL48EV;EKn9EM;IAOI,4BAAA;IAAA,+BAAA;ELg9EV;EKv9EM;IAOI,yBAAA;ELm9EV;EK19EM;IAOI,+BAAA;ELs9EV;EK79EM;IAOI,8BAAA;ELy9EV;EKh+EM;IAOI,4BAAA;EL49EV;EKn+EM;IAOI,8BAAA;EL+9EV;EKt+EM;IAOI,4BAAA;ELk+EV;EKz+EM;IAOI,2BAAA;ELq+EV;EK5+EM;IAOI,iCAAA;ELw+EV;EK/+EM;IAOI,gCAAA;EL2+EV;EKl/EM;IAOI,8BAAA;EL8+EV;EKr/EM;IAOI,gCAAA;ELi/EV;EKx/EM;IAOI,8BAAA;ELo/EV;EK3/EM;IAOI,4BAAA;ELu/EV;EK9/EM;IAOI,kCAAA;EL0/EV;EKjgFM;IAOI,iCAAA;EL6/EV;EKpgFM;IAOI,+BAAA;ELggFV;EKvgFM;IAOI,iCAAA;ELmgFV;EK1gFM;IAOI,+BAAA;ELsgFV;EK7gFM;IAOI,0BAAA;ELygFV;EKhhFM;IAOI,gCAAA;EL4gFV;EKnhFM;IAOI,+BAAA;EL+gFV;EKthFM;IAOI,6BAAA;ELkhFV;EKzhFM;IAOI,+BAAA;ELqhFV;EK5hFM;IAOI,6BAAA;ELwhFV;AACF;ACniFI;EIGI;IAOI,0BAAA;EL6hFV;EKpiFM;IAOI,gCAAA;ELgiFV;EKviFM;IAOI,yBAAA;ELmiFV;EK1iFM;IAOI,wBAAA;ELsiFV;EK7iFM;IAOI,+BAAA;ELyiFV;EKhjFM;IAOI,yBAAA;EL4iFV;EKnjFM;IAOI,6BAAA;EL+iFV;EKtjFM;IAOI,8BAAA;ELkjFV;EKzjFM;IAOI,wBAAA;ELqjFV;EK5jFM;IAOI,+BAAA;ELwjFV;EK/jFM;IAOI,wBAAA;EL2jFV;EKlkFM;IAOI,yBAAA;EL8jFV;EKrkFM;IAOI,8BAAA;ELikFV;EKxkFM;IAOI,iCAAA;ELokFV;EK3kFM;IAOI,sCAAA;ELukFV;EK9kFM;IAOI,yCAAA;EL0kFV;EKjlFM;IAOI,uBAAA;EL6kFV;EKplFM;IAOI,uBAAA;ELglFV;EKvlFM;IAOI,yBAAA;ELmlFV;EK1lFM;IAOI,yBAAA;ELslFV;EK7lFM;IAOI,0BAAA;ELylFV;EKhmFM;IAOI,4BAAA;EL4lFV;EKnmFM;IAOI,kCAAA;EL+lFV;EKtmFM;IAOI,sCAAA;ELkmFV;EKzmFM;IAOI,oCAAA;ELqmFV;EK5mFM;IAOI,kCAAA;ELwmFV;EK/mFM;IAOI,yCAAA;EL2mFV;EKlnFM;IAOI,wCAAA;EL8mFV;EKrnFM;IAOI,wCAAA;ELinFV;EKxnFM;IAOI,kCAAA;ELonFV;EK3nFM;IAOI,gCAAA;ELunFV;EK9nFM;IAOI,8BAAA;EL0nFV;EKjoFM;IAOI,gCAAA;EL6nFV;EKpoFM;IAOI,+BAAA;ELgoFV;EKvoFM;IAOI,oCAAA;ELmoFV;EK1oFM;IAOI,kCAAA;ELsoFV;EK7oFM;IAOI,gCAAA;ELyoFV;EKhpFM;IAOI,uCAAA;EL4oFV;EKnpFM;IAOI,sCAAA;EL+oFV;EKtpFM;IAOI,iCAAA;ELkpFV;EKzpFM;IAOI,2BAAA;ELqpFV;EK5pFM;IAOI,iCAAA;ELwpFV;EK/pFM;IAOI,+BAAA;EL2pFV;EKlqFM;IAOI,6BAAA;EL8pFV;EKrqFM;IAOI,+BAAA;ELiqFV;EKxqFM;IAOI,8BAAA;ELoqFV;EK3qFM;IAOI,oBAAA;ELuqFV;EK9qFM;IAOI,mBAAA;EL0qFV;EKjrFM;IAOI,mBAAA;EL6qFV;EKprFM;IAOI,mBAAA;ELgrFV;EKvrFM;IAOI,mBAAA;ELmrFV;EK1rFM;IAOI,mBAAA;ELsrFV;EK7rFM;IAOI,mBAAA;ELyrFV;EKhsFM;IAOI,mBAAA;EL4rFV;EKnsFM;IAOI,oBAAA;EL+rFV;EKtsFM;IAOI,0BAAA;ELksFV;EKzsFM;IAOI,yBAAA;ELqsFV;EK5sFM;IAOI,uBAAA;ELwsFV;EK/sFM;IAOI,yBAAA;EL2sFV;EKltFM;IAOI,uBAAA;EL8sFV;EKrtFM;IAOI,uBAAA;ELitFV;EKxtFM;IAOI,0BAAA;IAAA,yBAAA;ELqtFV;EK5tFM;IAOI,gCAAA;IAAA,+BAAA;ELytFV;EKhuFM;IAOI,+BAAA;IAAA,8BAAA;EL6tFV;EKpuFM;IAOI,6BAAA;IAAA,4BAAA;ELiuFV;EKxuFM;IAOI,+BAAA;IAAA,8BAAA;ELquFV;EK5uFM;IAOI,6BAAA;IAAA,4BAAA;ELyuFV;EKhvFM;IAOI,6BAAA;IAAA,4BAAA;EL6uFV;EKpvFM;IAOI,wBAAA;IAAA,2BAAA;ELivFV;EKxvFM;IAOI,8BAAA;IAAA,iCAAA;ELqvFV;EK5vFM;IAOI,6BAAA;IAAA,gCAAA;ELyvFV;EKhwFM;IAOI,2BAAA;IAAA,8BAAA;EL6vFV;EKpwFM;IAOI,6BAAA;IAAA,gCAAA;ELiwFV;EKxwFM;IAOI,2BAAA;IAAA,8BAAA;ELqwFV;EK5wFM;IAOI,2BAAA;IAAA,8BAAA;ELywFV;EKhxFM;IAOI,wBAAA;EL4wFV;EKnxFM;IAOI,8BAAA;EL+wFV;EKtxFM;IAOI,6BAAA;ELkxFV;EKzxFM;IAOI,2BAAA;ELqxFV;EK5xFM;IAOI,6BAAA;ELwxFV;EK/xFM;IAOI,2BAAA;EL2xFV;EKlyFM;IAOI,2BAAA;EL8xFV;EKryFM;IAOI,0BAAA;ELiyFV;EKxyFM;IAOI,gCAAA;ELoyFV;EK3yFM;IAOI,+BAAA;ELuyFV;EK9yFM;IAOI,6BAAA;EL0yFV;EKjzFM;IAOI,+BAAA;EL6yFV;EKpzFM;IAOI,6BAAA;ELgzFV;EKvzFM;IAOI,6BAAA;ELmzFV;EK1zFM;IAOI,2BAAA;ELszFV;EK7zFM;IAOI,iCAAA;ELyzFV;EKh0FM;IAOI,gCAAA;EL4zFV;EKn0FM;IAOI,8BAAA;EL+zFV;EKt0FM;IAOI,gCAAA;ELk0FV;EKz0FM;IAOI,8BAAA;ELq0FV;EK50FM;IAOI,8BAAA;ELw0FV;EK/0FM;IAOI,yBAAA;EL20FV;EKl1FM;IAOI,+BAAA;EL80FV;EKr1FM;IAOI,8BAAA;ELi1FV;EKx1FM;IAOI,4BAAA;ELo1FV;EK31FM;IAOI,8BAAA;ELu1FV;EK91FM;IAOI,4BAAA;EL01FV;EKj2FM;IAOI,4BAAA;EL61FV;EKp2FM;IAOI,qBAAA;ELg2FV;EKv2FM;IAOI,2BAAA;ELm2FV;EK12FM;IAOI,0BAAA;ELs2FV;EK72FM;IAOI,wBAAA;ELy2FV;EKh3FM;IAOI,0BAAA;EL42FV;EKn3FM;IAOI,wBAAA;EL+2FV;EKt3FM;IAOI,2BAAA;IAAA,0BAAA;ELm3FV;EK13FM;IAOI,iCAAA;IAAA,gCAAA;ELu3FV;EK93FM;IAOI,gCAAA;IAAA,+BAAA;EL23FV;EKl4FM;IAOI,8BAAA;IAAA,6BAAA;EL+3FV;EKt4FM;IAOI,gCAAA;IAAA,+BAAA;ELm4FV;EK14FM;IAOI,8BAAA;IAAA,6BAAA;ELu4FV;EK94FM;IAOI,yBAAA;IAAA,4BAAA;EL24FV;EKl5FM;IAOI,+BAAA;IAAA,kCAAA;EL+4FV;EKt5FM;IAOI,8BAAA;IAAA,iCAAA;ELm5FV;EK15FM;IAOI,4BAAA;IAAA,+BAAA;ELu5FV;EK95FM;IAOI,8BAAA;IAAA,iCAAA;EL25FV;EKl6FM;IAOI,4BAAA;IAAA,+BAAA;EL+5FV;EKt6FM;IAOI,yBAAA;ELk6FV;EKz6FM;IAOI,+BAAA;ELq6FV;EK56FM;IAOI,8BAAA;ELw6FV;EK/6FM;IAOI,4BAAA;EL26FV;EKl7FM;IAOI,8BAAA;EL86FV;EKr7FM;IAOI,4BAAA;ELi7FV;EKx7FM;IAOI,2BAAA;ELo7FV;EK37FM;IAOI,iCAAA;ELu7FV;EK97FM;IAOI,gCAAA;EL07FV;EKj8FM;IAOI,8BAAA;EL67FV;EKp8FM;IAOI,gCAAA;ELg8FV;EKv8FM;IAOI,8BAAA;ELm8FV;EK18FM;IAOI,4BAAA;ELs8FV;EK78FM;IAOI,kCAAA;ELy8FV;EKh9FM;IAOI,iCAAA;EL48FV;EKn9FM;IAOI,+BAAA;EL+8FV;EKt9FM;IAOI,iCAAA;ELk9FV;EKz9FM;IAOI,+BAAA;ELq9FV;EK59FM;IAOI,0BAAA;ELw9FV;EK/9FM;IAOI,gCAAA;EL29FV;EKl+FM;IAOI,+BAAA;EL89FV;EKr+FM;IAOI,6BAAA;ELi+FV;EKx+FM;IAOI,+BAAA;ELo+FV;EK3+FM;IAOI,6BAAA;ELu+FV;AACF;ACl/FI;EIGI;IAOI,0BAAA;EL4+FV;EKn/FM;IAOI,gCAAA;EL++FV;EKt/FM;IAOI,yBAAA;ELk/FV;EKz/FM;IAOI,wBAAA;ELq/FV;EK5/FM;IAOI,+BAAA;ELw/FV;EK//FM;IAOI,yBAAA;EL2/FV;EKlgGM;IAOI,6BAAA;EL8/FV;EKrgGM;IAOI,8BAAA;ELigGV;EKxgGM;IAOI,wBAAA;ELogGV;EK3gGM;IAOI,+BAAA;ELugGV;EK9gGM;IAOI,wBAAA;EL0gGV;EKjhGM;IAOI,yBAAA;EL6gGV;EKphGM;IAOI,8BAAA;ELghGV;EKvhGM;IAOI,iCAAA;ELmhGV;EK1hGM;IAOI,sCAAA;ELshGV;EK7hGM;IAOI,yCAAA;ELyhGV;EKhiGM;IAOI,uBAAA;EL4hGV;EKniGM;IAOI,uBAAA;EL+hGV;EKtiGM;IAOI,yBAAA;ELkiGV;EKziGM;IAOI,yBAAA;ELqiGV;EK5iGM;IAOI,0BAAA;ELwiGV;EK/iGM;IAOI,4BAAA;EL2iGV;EKljGM;IAOI,kCAAA;EL8iGV;EKrjGM;IAOI,sCAAA;ELijGV;EKxjGM;IAOI,oCAAA;ELojGV;EK3jGM;IAOI,kCAAA;ELujGV;EK9jGM;IAOI,yCAAA;EL0jGV;EKjkGM;IAOI,wCAAA;EL6jGV;EKpkGM;IAOI,wCAAA;ELgkGV;EKvkGM;IAOI,kCAAA;ELmkGV;EK1kGM;IAOI,gCAAA;ELskGV;EK7kGM;IAOI,8BAAA;ELykGV;EKhlGM;IAOI,gCAAA;EL4kGV;EKnlGM;IAOI,+BAAA;EL+kGV;EKtlGM;IAOI,oCAAA;ELklGV;EKzlGM;IAOI,kCAAA;ELqlGV;EK5lGM;IAOI,gCAAA;ELwlGV;EK/lGM;IAOI,uCAAA;EL2lGV;EKlmGM;IAOI,sCAAA;EL8lGV;EKrmGM;IAOI,iCAAA;ELimGV;EKxmGM;IAOI,2BAAA;ELomGV;EK3mGM;IAOI,iCAAA;ELumGV;EK9mGM;IAOI,+BAAA;EL0mGV;EKjnGM;IAOI,6BAAA;EL6mGV;EKpnGM;IAOI,+BAAA;ELgnGV;EKvnGM;IAOI,8BAAA;ELmnGV;EK1nGM;IAOI,oBAAA;ELsnGV;EK7nGM;IAOI,mBAAA;ELynGV;EKhoGM;IAOI,mBAAA;EL4nGV;EKnoGM;IAOI,mBAAA;EL+nGV;EKtoGM;IAOI,mBAAA;ELkoGV;EKzoGM;IAOI,mBAAA;ELqoGV;EK5oGM;IAOI,mBAAA;ELwoGV;EK/oGM;IAOI,mBAAA;EL2oGV;EKlpGM;IAOI,oBAAA;EL8oGV;EKrpGM;IAOI,0BAAA;ELipGV;EKxpGM;IAOI,yBAAA;ELopGV;EK3pGM;IAOI,uBAAA;ELupGV;EK9pGM;IAOI,yBAAA;EL0pGV;EKjqGM;IAOI,uBAAA;EL6pGV;EKpqGM;IAOI,uBAAA;ELgqGV;EKvqGM;IAOI,0BAAA;IAAA,yBAAA;ELoqGV;EK3qGM;IAOI,gCAAA;IAAA,+BAAA;ELwqGV;EK/qGM;IAOI,+BAAA;IAAA,8BAAA;EL4qGV;EKnrGM;IAOI,6BAAA;IAAA,4BAAA;ELgrGV;EKvrGM;IAOI,+BAAA;IAAA,8BAAA;ELorGV;EK3rGM;IAOI,6BAAA;IAAA,4BAAA;ELwrGV;EK/rGM;IAOI,6BAAA;IAAA,4BAAA;EL4rGV;EKnsGM;IAOI,wBAAA;IAAA,2BAAA;ELgsGV;EKvsGM;IAOI,8BAAA;IAAA,iCAAA;ELosGV;EK3sGM;IAOI,6BAAA;IAAA,gCAAA;ELwsGV;EK/sGM;IAOI,2BAAA;IAAA,8BAAA;EL4sGV;EKntGM;IAOI,6BAAA;IAAA,gCAAA;ELgtGV;EKvtGM;IAOI,2BAAA;IAAA,8BAAA;ELotGV;EK3tGM;IAOI,2BAAA;IAAA,8BAAA;ELwtGV;EK/tGM;IAOI,wBAAA;EL2tGV;EKluGM;IAOI,8BAAA;EL8tGV;EKruGM;IAOI,6BAAA;ELiuGV;EKxuGM;IAOI,2BAAA;ELouGV;EK3uGM;IAOI,6BAAA;ELuuGV;EK9uGM;IAOI,2BAAA;EL0uGV;EKjvGM;IAOI,2BAAA;EL6uGV;EKpvGM;IAOI,0BAAA;ELgvGV;EKvvGM;IAOI,gCAAA;ELmvGV;EK1vGM;IAOI,+BAAA;ELsvGV;EK7vGM;IAOI,6BAAA;ELyvGV;EKhwGM;IAOI,+BAAA;EL4vGV;EKnwGM;IAOI,6BAAA;EL+vGV;EKtwGM;IAOI,6BAAA;ELkwGV;EKzwGM;IAOI,2BAAA;ELqwGV;EK5wGM;IAOI,iCAAA;ELwwGV;EK/wGM;IAOI,gCAAA;EL2wGV;EKlxGM;IAOI,8BAAA;EL8wGV;EKrxGM;IAOI,gCAAA;ELixGV;EKxxGM;IAOI,8BAAA;ELoxGV;EK3xGM;IAOI,8BAAA;ELuxGV;EK9xGM;IAOI,yBAAA;EL0xGV;EKjyGM;IAOI,+BAAA;EL6xGV;EKpyGM;IAOI,8BAAA;ELgyGV;EKvyGM;IAOI,4BAAA;ELmyGV;EK1yGM;IAOI,8BAAA;ELsyGV;EK7yGM;IAOI,4BAAA;ELyyGV;EKhzGM;IAOI,4BAAA;EL4yGV;EKnzGM;IAOI,qBAAA;EL+yGV;EKtzGM;IAOI,2BAAA;ELkzGV;EKzzGM;IAOI,0BAAA;ELqzGV;EK5zGM;IAOI,wBAAA;ELwzGV;EK/zGM;IAOI,0BAAA;EL2zGV;EKl0GM;IAOI,wBAAA;EL8zGV;EKr0GM;IAOI,2BAAA;IAAA,0BAAA;ELk0GV;EKz0GM;IAOI,iCAAA;IAAA,gCAAA;ELs0GV;EK70GM;IAOI,gCAAA;IAAA,+BAAA;EL00GV;EKj1GM;IAOI,8BAAA;IAAA,6BAAA;EL80GV;EKr1GM;IAOI,gCAAA;IAAA,+BAAA;ELk1GV;EKz1GM;IAOI,8BAAA;IAAA,6BAAA;ELs1GV;EK71GM;IAOI,yBAAA;IAAA,4BAAA;EL01GV;EKj2GM;IAOI,+BAAA;IAAA,kCAAA;EL81GV;EKr2GM;IAOI,8BAAA;IAAA,iCAAA;ELk2GV;EKz2GM;IAOI,4BAAA;IAAA,+BAAA;ELs2GV;EK72GM;IAOI,8BAAA;IAAA,iCAAA;EL02GV;EKj3GM;IAOI,4BAAA;IAAA,+BAAA;EL82GV;EKr3GM;IAOI,yBAAA;ELi3GV;EKx3GM;IAOI,+BAAA;ELo3GV;EK33GM;IAOI,8BAAA;ELu3GV;EK93GM;IAOI,4BAAA;EL03GV;EKj4GM;IAOI,8BAAA;EL63GV;EKp4GM;IAOI,4BAAA;ELg4GV;EKv4GM;IAOI,2BAAA;ELm4GV;EK14GM;IAOI,iCAAA;ELs4GV;EK74GM;IAOI,gCAAA;ELy4GV;EKh5GM;IAOI,8BAAA;EL44GV;EKn5GM;IAOI,gCAAA;EL+4GV;EKt5GM;IAOI,8BAAA;ELk5GV;EKz5GM;IAOI,4BAAA;ELq5GV;EK55GM;IAOI,kCAAA;ELw5GV;EK/5GM;IAOI,iCAAA;EL25GV;EKl6GM;IAOI,+BAAA;EL85GV;EKr6GM;IAOI,iCAAA;ELi6GV;EKx6GM;IAOI,+BAAA;ELo6GV;EK36GM;IAOI,0BAAA;ELu6GV;EK96GM;IAOI,gCAAA;EL06GV;EKj7GM;IAOI,+BAAA;EL66GV;EKp7GM;IAOI,6BAAA;ELg7GV;EKv7GM;IAOI,+BAAA;ELm7GV;EK17GM;IAOI,6BAAA;ELs7GV;AACF;ACj8GI;EIGI;IAOI,0BAAA;EL27GV;EKl8GM;IAOI,gCAAA;EL87GV;EKr8GM;IAOI,yBAAA;ELi8GV;EKx8GM;IAOI,wBAAA;ELo8GV;EK38GM;IAOI,+BAAA;ELu8GV;EK98GM;IAOI,yBAAA;EL08GV;EKj9GM;IAOI,6BAAA;EL68GV;EKp9GM;IAOI,8BAAA;ELg9GV;EKv9GM;IAOI,wBAAA;ELm9GV;EK19GM;IAOI,+BAAA;ELs9GV;EK79GM;IAOI,wBAAA;ELy9GV;EKh+GM;IAOI,yBAAA;EL49GV;EKn+GM;IAOI,8BAAA;EL+9GV;EKt+GM;IAOI,iCAAA;ELk+GV;EKz+GM;IAOI,sCAAA;ELq+GV;EK5+GM;IAOI,yCAAA;ELw+GV;EK/+GM;IAOI,uBAAA;EL2+GV;EKl/GM;IAOI,uBAAA;EL8+GV;EKr/GM;IAOI,yBAAA;ELi/GV;EKx/GM;IAOI,yBAAA;ELo/GV;EK3/GM;IAOI,0BAAA;ELu/GV;EK9/GM;IAOI,4BAAA;EL0/GV;EKjgHM;IAOI,kCAAA;EL6/GV;EKpgHM;IAOI,sCAAA;ELggHV;EKvgHM;IAOI,oCAAA;ELmgHV;EK1gHM;IAOI,kCAAA;ELsgHV;EK7gHM;IAOI,yCAAA;ELygHV;EKhhHM;IAOI,wCAAA;EL4gHV;EKnhHM;IAOI,wCAAA;EL+gHV;EKthHM;IAOI,kCAAA;ELkhHV;EKzhHM;IAOI,gCAAA;ELqhHV;EK5hHM;IAOI,8BAAA;ELwhHV;EK/hHM;IAOI,gCAAA;EL2hHV;EKliHM;IAOI,+BAAA;EL8hHV;EKriHM;IAOI,oCAAA;ELiiHV;EKxiHM;IAOI,kCAAA;ELoiHV;EK3iHM;IAOI,gCAAA;ELuiHV;EK9iHM;IAOI,uCAAA;EL0iHV;EKjjHM;IAOI,sCAAA;EL6iHV;EKpjHM;IAOI,iCAAA;ELgjHV;EKvjHM;IAOI,2BAAA;ELmjHV;EK1jHM;IAOI,iCAAA;ELsjHV;EK7jHM;IAOI,+BAAA;ELyjHV;EKhkHM;IAOI,6BAAA;EL4jHV;EKnkHM;IAOI,+BAAA;EL+jHV;EKtkHM;IAOI,8BAAA;ELkkHV;EKzkHM;IAOI,oBAAA;ELqkHV;EK5kHM;IAOI,mBAAA;ELwkHV;EK/kHM;IAOI,mBAAA;EL2kHV;EKllHM;IAOI,mBAAA;EL8kHV;EKrlHM;IAOI,mBAAA;ELilHV;EKxlHM;IAOI,mBAAA;ELolHV;EK3lHM;IAOI,mBAAA;ELulHV;EK9lHM;IAOI,mBAAA;EL0lHV;EKjmHM;IAOI,oBAAA;EL6lHV;EKpmHM;IAOI,0BAAA;ELgmHV;EKvmHM;IAOI,yBAAA;ELmmHV;EK1mHM;IAOI,uBAAA;ELsmHV;EK7mHM;IAOI,yBAAA;ELymHV;EKhnHM;IAOI,uBAAA;EL4mHV;EKnnHM;IAOI,uBAAA;EL+mHV;EKtnHM;IAOI,0BAAA;IAAA,yBAAA;ELmnHV;EK1nHM;IAOI,gCAAA;IAAA,+BAAA;ELunHV;EK9nHM;IAOI,+BAAA;IAAA,8BAAA;EL2nHV;EKloHM;IAOI,6BAAA;IAAA,4BAAA;EL+nHV;EKtoHM;IAOI,+BAAA;IAAA,8BAAA;ELmoHV;EK1oHM;IAOI,6BAAA;IAAA,4BAAA;ELuoHV;EK9oHM;IAOI,6BAAA;IAAA,4BAAA;EL2oHV;EKlpHM;IAOI,wBAAA;IAAA,2BAAA;EL+oHV;EKtpHM;IAOI,8BAAA;IAAA,iCAAA;ELmpHV;EK1pHM;IAOI,6BAAA;IAAA,gCAAA;ELupHV;EK9pHM;IAOI,2BAAA;IAAA,8BAAA;EL2pHV;EKlqHM;IAOI,6BAAA;IAAA,gCAAA;EL+pHV;EKtqHM;IAOI,2BAAA;IAAA,8BAAA;ELmqHV;EK1qHM;IAOI,2BAAA;IAAA,8BAAA;ELuqHV;EK9qHM;IAOI,wBAAA;EL0qHV;EKjrHM;IAOI,8BAAA;EL6qHV;EKprHM;IAOI,6BAAA;ELgrHV;EKvrHM;IAOI,2BAAA;ELmrHV;EK1rHM;IAOI,6BAAA;ELsrHV;EK7rHM;IAOI,2BAAA;ELyrHV;EKhsHM;IAOI,2BAAA;EL4rHV;EKnsHM;IAOI,0BAAA;EL+rHV;EKtsHM;IAOI,gCAAA;ELksHV;EKzsHM;IAOI,+BAAA;ELqsHV;EK5sHM;IAOI,6BAAA;ELwsHV;EK/sHM;IAOI,+BAAA;EL2sHV;EKltHM;IAOI,6BAAA;EL8sHV;EKrtHM;IAOI,6BAAA;ELitHV;EKxtHM;IAOI,2BAAA;ELotHV;EK3tHM;IAOI,iCAAA;ELutHV;EK9tHM;IAOI,gCAAA;EL0tHV;EKjuHM;IAOI,8BAAA;EL6tHV;EKpuHM;IAOI,gCAAA;ELguHV;EKvuHM;IAOI,8BAAA;ELmuHV;EK1uHM;IAOI,8BAAA;ELsuHV;EK7uHM;IAOI,yBAAA;ELyuHV;EKhvHM;IAOI,+BAAA;EL4uHV;EKnvHM;IAOI,8BAAA;EL+uHV;EKtvHM;IAOI,4BAAA;ELkvHV;EKzvHM;IAOI,8BAAA;ELqvHV;EK5vHM;IAOI,4BAAA;ELwvHV;EK/vHM;IAOI,4BAAA;EL2vHV;EKlwHM;IAOI,qBAAA;EL8vHV;EKrwHM;IAOI,2BAAA;ELiwHV;EKxwHM;IAOI,0BAAA;ELowHV;EK3wHM;IAOI,wBAAA;ELuwHV;EK9wHM;IAOI,0BAAA;EL0wHV;EKjxHM;IAOI,wBAAA;EL6wHV;EKpxHM;IAOI,2BAAA;IAAA,0BAAA;ELixHV;EKxxHM;IAOI,iCAAA;IAAA,gCAAA;ELqxHV;EK5xHM;IAOI,gCAAA;IAAA,+BAAA;ELyxHV;EKhyHM;IAOI,8BAAA;IAAA,6BAAA;EL6xHV;EKpyHM;IAOI,gCAAA;IAAA,+BAAA;ELiyHV;EKxyHM;IAOI,8BAAA;IAAA,6BAAA;ELqyHV;EK5yHM;IAOI,yBAAA;IAAA,4BAAA;ELyyHV;EKhzHM;IAOI,+BAAA;IAAA,kCAAA;EL6yHV;EKpzHM;IAOI,8BAAA;IAAA,iCAAA;ELizHV;EKxzHM;IAOI,4BAAA;IAAA,+BAAA;ELqzHV;EK5zHM;IAOI,8BAAA;IAAA,iCAAA;ELyzHV;EKh0HM;IAOI,4BAAA;IAAA,+BAAA;EL6zHV;EKp0HM;IAOI,yBAAA;ELg0HV;EKv0HM;IAOI,+BAAA;ELm0HV;EK10HM;IAOI,8BAAA;ELs0HV;EK70HM;IAOI,4BAAA;ELy0HV;EKh1HM;IAOI,8BAAA;EL40HV;EKn1HM;IAOI,4BAAA;EL+0HV;EKt1HM;IAOI,2BAAA;ELk1HV;EKz1HM;IAOI,iCAAA;ELq1HV;EK51HM;IAOI,gCAAA;ELw1HV;EK/1HM;IAOI,8BAAA;EL21HV;EKl2HM;IAOI,gCAAA;EL81HV;EKr2HM;IAOI,8BAAA;ELi2HV;EKx2HM;IAOI,4BAAA;ELo2HV;EK32HM;IAOI,kCAAA;ELu2HV;EK92HM;IAOI,iCAAA;EL02HV;EKj3HM;IAOI,+BAAA;EL62HV;EKp3HM;IAOI,iCAAA;ELg3HV;EKv3HM;IAOI,+BAAA;ELm3HV;EK13HM;IAOI,0BAAA;ELs3HV;EK73HM;IAOI,gCAAA;ELy3HV;EKh4HM;IAOI,+BAAA;EL43HV;EKn4HM;IAOI,6BAAA;EL+3HV;EKt4HM;IAOI,+BAAA;ELk4HV;EKz4HM;IAOI,6BAAA;ELq4HV;AACF;AMz6HA;ED4BQ;IAOI,0BAAA;EL04HV;EKj5HM;IAOI,gCAAA;EL64HV;EKp5HM;IAOI,yBAAA;ELg5HV;EKv5HM;IAOI,wBAAA;ELm5HV;EK15HM;IAOI,+BAAA;ELs5HV;EK75HM;IAOI,yBAAA;ELy5HV;EKh6HM;IAOI,6BAAA;EL45HV;EKn6HM;IAOI,8BAAA;EL+5HV;EKt6HM;IAOI,wBAAA;ELk6HV;EKz6HM;IAOI,+BAAA;ELq6HV;EK56HM;IAOI,wBAAA;ELw6HV;AACF","file":"bootstrap-grid.css","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap-grid.css.map */\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Variables\n//\n// Variables should follow the `$component-state-property-size` formula for\n// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.\n\n// Color system\n\n// scss-docs-start gray-color-variables\n$white: #fff !default;\n$gray-100: #f8f9fa !default;\n$gray-200: #e9ecef !default;\n$gray-300: #dee2e6 !default;\n$gray-400: #ced4da !default;\n$gray-500: #adb5bd !default;\n$gray-600: #6c757d !default;\n$gray-700: #495057 !default;\n$gray-800: #343a40 !default;\n$gray-900: #212529 !default;\n$black: #000 !default;\n// scss-docs-end gray-color-variables\n\n// fusv-disable\n// scss-docs-start gray-colors-map\n$grays: (\n \"100\": $gray-100,\n \"200\": $gray-200,\n \"300\": $gray-300,\n \"400\": $gray-400,\n \"500\": $gray-500,\n \"600\": $gray-600,\n \"700\": $gray-700,\n \"800\": $gray-800,\n \"900\": $gray-900\n) !default;\n// scss-docs-end gray-colors-map\n// fusv-enable\n\n// scss-docs-start color-variables\n$blue: #0d6efd !default;\n$indigo: #6610f2 !default;\n$purple: #6f42c1 !default;\n$pink: #d63384 !default;\n$red: #dc3545 !default;\n$orange: #fd7e14 !default;\n$yellow: #ffc107 !default;\n$green: #198754 !default;\n$teal: #20c997 !default;\n$cyan: #0dcaf0 !default;\n// scss-docs-end color-variables\n\n// scss-docs-start colors-map\n$colors: (\n \"blue\": $blue,\n \"indigo\": $indigo,\n \"purple\": $purple,\n \"pink\": $pink,\n \"red\": $red,\n \"orange\": $orange,\n \"yellow\": $yellow,\n \"green\": $green,\n \"teal\": $teal,\n \"cyan\": $cyan,\n \"black\": $black,\n \"white\": $white,\n \"gray\": $gray-600,\n \"gray-dark\": $gray-800\n) !default;\n// scss-docs-end colors-map\n\n// The contrast ratio to reach against white, to determine if color changes from \"light\" to \"dark\". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.\n// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast\n$min-contrast-ratio: 4.5 !default;\n\n// Customize the light and dark text colors for use in our color contrast function.\n$color-contrast-dark: $black !default;\n$color-contrast-light: $white !default;\n\n// fusv-disable\n$blue-100: tint-color($blue, 80%) !default;\n$blue-200: tint-color($blue, 60%) !default;\n$blue-300: tint-color($blue, 40%) !default;\n$blue-400: tint-color($blue, 20%) !default;\n$blue-500: $blue !default;\n$blue-600: shade-color($blue, 20%) !default;\n$blue-700: shade-color($blue, 40%) !default;\n$blue-800: shade-color($blue, 60%) !default;\n$blue-900: shade-color($blue, 80%) !default;\n\n$indigo-100: tint-color($indigo, 80%) !default;\n$indigo-200: tint-color($indigo, 60%) !default;\n$indigo-300: tint-color($indigo, 40%) !default;\n$indigo-400: tint-color($indigo, 20%) !default;\n$indigo-500: $indigo !default;\n$indigo-600: shade-color($indigo, 20%) !default;\n$indigo-700: shade-color($indigo, 40%) !default;\n$indigo-800: shade-color($indigo, 60%) !default;\n$indigo-900: shade-color($indigo, 80%) !default;\n\n$purple-100: tint-color($purple, 80%) !default;\n$purple-200: tint-color($purple, 60%) !default;\n$purple-300: tint-color($purple, 40%) !default;\n$purple-400: tint-color($purple, 20%) !default;\n$purple-500: $purple !default;\n$purple-600: shade-color($purple, 20%) !default;\n$purple-700: shade-color($purple, 40%) !default;\n$purple-800: shade-color($purple, 60%) !default;\n$purple-900: shade-color($purple, 80%) !default;\n\n$pink-100: tint-color($pink, 80%) !default;\n$pink-200: tint-color($pink, 60%) !default;\n$pink-300: tint-color($pink, 40%) !default;\n$pink-400: tint-color($pink, 20%) !default;\n$pink-500: $pink !default;\n$pink-600: shade-color($pink, 20%) !default;\n$pink-700: shade-color($pink, 40%) !default;\n$pink-800: shade-color($pink, 60%) !default;\n$pink-900: shade-color($pink, 80%) !default;\n\n$red-100: tint-color($red, 80%) !default;\n$red-200: tint-color($red, 60%) !default;\n$red-300: tint-color($red, 40%) !default;\n$red-400: tint-color($red, 20%) !default;\n$red-500: $red !default;\n$red-600: shade-color($red, 20%) !default;\n$red-700: shade-color($red, 40%) !default;\n$red-800: shade-color($red, 60%) !default;\n$red-900: shade-color($red, 80%) !default;\n\n$orange-100: tint-color($orange, 80%) !default;\n$orange-200: tint-color($orange, 60%) !default;\n$orange-300: tint-color($orange, 40%) !default;\n$orange-400: tint-color($orange, 20%) !default;\n$orange-500: $orange !default;\n$orange-600: shade-color($orange, 20%) !default;\n$orange-700: shade-color($orange, 40%) !default;\n$orange-800: shade-color($orange, 60%) !default;\n$orange-900: shade-color($orange, 80%) !default;\n\n$yellow-100: tint-color($yellow, 80%) !default;\n$yellow-200: tint-color($yellow, 60%) !default;\n$yellow-300: tint-color($yellow, 40%) !default;\n$yellow-400: tint-color($yellow, 20%) !default;\n$yellow-500: $yellow !default;\n$yellow-600: shade-color($yellow, 20%) !default;\n$yellow-700: shade-color($yellow, 40%) !default;\n$yellow-800: shade-color($yellow, 60%) !default;\n$yellow-900: shade-color($yellow, 80%) !default;\n\n$green-100: tint-color($green, 80%) !default;\n$green-200: tint-color($green, 60%) !default;\n$green-300: tint-color($green, 40%) !default;\n$green-400: tint-color($green, 20%) !default;\n$green-500: $green !default;\n$green-600: shade-color($green, 20%) !default;\n$green-700: shade-color($green, 40%) !default;\n$green-800: shade-color($green, 60%) !default;\n$green-900: shade-color($green, 80%) !default;\n\n$teal-100: tint-color($teal, 80%) !default;\n$teal-200: tint-color($teal, 60%) !default;\n$teal-300: tint-color($teal, 40%) !default;\n$teal-400: tint-color($teal, 20%) !default;\n$teal-500: $teal !default;\n$teal-600: shade-color($teal, 20%) !default;\n$teal-700: shade-color($teal, 40%) !default;\n$teal-800: shade-color($teal, 60%) !default;\n$teal-900: shade-color($teal, 80%) !default;\n\n$cyan-100: tint-color($cyan, 80%) !default;\n$cyan-200: tint-color($cyan, 60%) !default;\n$cyan-300: tint-color($cyan, 40%) !default;\n$cyan-400: tint-color($cyan, 20%) !default;\n$cyan-500: $cyan !default;\n$cyan-600: shade-color($cyan, 20%) !default;\n$cyan-700: shade-color($cyan, 40%) !default;\n$cyan-800: shade-color($cyan, 60%) !default;\n$cyan-900: shade-color($cyan, 80%) !default;\n\n$blues: (\n \"blue-100\": $blue-100,\n \"blue-200\": $blue-200,\n \"blue-300\": $blue-300,\n \"blue-400\": $blue-400,\n \"blue-500\": $blue-500,\n \"blue-600\": $blue-600,\n \"blue-700\": $blue-700,\n \"blue-800\": $blue-800,\n \"blue-900\": $blue-900\n) !default;\n\n$indigos: (\n \"indigo-100\": $indigo-100,\n \"indigo-200\": $indigo-200,\n \"indigo-300\": $indigo-300,\n \"indigo-400\": $indigo-400,\n \"indigo-500\": $indigo-500,\n \"indigo-600\": $indigo-600,\n \"indigo-700\": $indigo-700,\n \"indigo-800\": $indigo-800,\n \"indigo-900\": $indigo-900\n) !default;\n\n$purples: (\n \"purple-100\": $purple-100,\n \"purple-200\": $purple-200,\n \"purple-300\": $purple-300,\n \"purple-400\": $purple-400,\n \"purple-500\": $purple-500,\n \"purple-600\": $purple-600,\n \"purple-700\": $purple-700,\n \"purple-800\": $purple-800,\n \"purple-900\": $purple-900\n) !default;\n\n$pinks: (\n \"pink-100\": $pink-100,\n \"pink-200\": $pink-200,\n \"pink-300\": $pink-300,\n \"pink-400\": $pink-400,\n \"pink-500\": $pink-500,\n \"pink-600\": $pink-600,\n \"pink-700\": $pink-700,\n \"pink-800\": $pink-800,\n \"pink-900\": $pink-900\n) !default;\n\n$reds: (\n \"red-100\": $red-100,\n \"red-200\": $red-200,\n \"red-300\": $red-300,\n \"red-400\": $red-400,\n \"red-500\": $red-500,\n \"red-600\": $red-600,\n \"red-700\": $red-700,\n \"red-800\": $red-800,\n \"red-900\": $red-900\n) !default;\n\n$oranges: (\n \"orange-100\": $orange-100,\n \"orange-200\": $orange-200,\n \"orange-300\": $orange-300,\n \"orange-400\": $orange-400,\n \"orange-500\": $orange-500,\n \"orange-600\": $orange-600,\n \"orange-700\": $orange-700,\n \"orange-800\": $orange-800,\n \"orange-900\": $orange-900\n) !default;\n\n$yellows: (\n \"yellow-100\": $yellow-100,\n \"yellow-200\": $yellow-200,\n \"yellow-300\": $yellow-300,\n \"yellow-400\": $yellow-400,\n \"yellow-500\": $yellow-500,\n \"yellow-600\": $yellow-600,\n \"yellow-700\": $yellow-700,\n \"yellow-800\": $yellow-800,\n \"yellow-900\": $yellow-900\n) !default;\n\n$greens: (\n \"green-100\": $green-100,\n \"green-200\": $green-200,\n \"green-300\": $green-300,\n \"green-400\": $green-400,\n \"green-500\": $green-500,\n \"green-600\": $green-600,\n \"green-700\": $green-700,\n \"green-800\": $green-800,\n \"green-900\": $green-900\n) !default;\n\n$teals: (\n \"teal-100\": $teal-100,\n \"teal-200\": $teal-200,\n \"teal-300\": $teal-300,\n \"teal-400\": $teal-400,\n \"teal-500\": $teal-500,\n \"teal-600\": $teal-600,\n \"teal-700\": $teal-700,\n \"teal-800\": $teal-800,\n \"teal-900\": $teal-900\n) !default;\n\n$cyans: (\n \"cyan-100\": $cyan-100,\n \"cyan-200\": $cyan-200,\n \"cyan-300\": $cyan-300,\n \"cyan-400\": $cyan-400,\n \"cyan-500\": $cyan-500,\n \"cyan-600\": $cyan-600,\n \"cyan-700\": $cyan-700,\n \"cyan-800\": $cyan-800,\n \"cyan-900\": $cyan-900\n) !default;\n// fusv-enable\n\n// scss-docs-start theme-color-variables\n$primary: $blue !default;\n$secondary: $gray-600 !default;\n$success: $green !default;\n$info: $cyan !default;\n$warning: $yellow !default;\n$danger: $red !default;\n$light: $gray-100 !default;\n$dark: $gray-900 !default;\n// scss-docs-end theme-color-variables\n\n// scss-docs-start theme-colors-map\n$theme-colors: (\n \"primary\": $primary,\n \"secondary\": $secondary,\n \"success\": $success,\n \"info\": $info,\n \"warning\": $warning,\n \"danger\": $danger,\n \"light\": $light,\n \"dark\": $dark\n) !default;\n// scss-docs-end theme-colors-map\n\n// scss-docs-start theme-text-variables\n$primary-text-emphasis: shade-color($primary, 60%) !default;\n$secondary-text-emphasis: shade-color($secondary, 60%) !default;\n$success-text-emphasis: shade-color($success, 60%) !default;\n$info-text-emphasis: shade-color($info, 60%) !default;\n$warning-text-emphasis: shade-color($warning, 60%) !default;\n$danger-text-emphasis: shade-color($danger, 60%) !default;\n$light-text-emphasis: $gray-700 !default;\n$dark-text-emphasis: $gray-700 !default;\n// scss-docs-end theme-text-variables\n\n// scss-docs-start theme-bg-subtle-variables\n$primary-bg-subtle: tint-color($primary, 80%) !default;\n$secondary-bg-subtle: tint-color($secondary, 80%) !default;\n$success-bg-subtle: tint-color($success, 80%) !default;\n$info-bg-subtle: tint-color($info, 80%) !default;\n$warning-bg-subtle: tint-color($warning, 80%) !default;\n$danger-bg-subtle: tint-color($danger, 80%) !default;\n$light-bg-subtle: mix($gray-100, $white) !default;\n$dark-bg-subtle: $gray-400 !default;\n// scss-docs-end theme-bg-subtle-variables\n\n// scss-docs-start theme-border-subtle-variables\n$primary-border-subtle: tint-color($primary, 60%) !default;\n$secondary-border-subtle: tint-color($secondary, 60%) !default;\n$success-border-subtle: tint-color($success, 60%) !default;\n$info-border-subtle: tint-color($info, 60%) !default;\n$warning-border-subtle: tint-color($warning, 60%) !default;\n$danger-border-subtle: tint-color($danger, 60%) !default;\n$light-border-subtle: $gray-200 !default;\n$dark-border-subtle: $gray-500 !default;\n// scss-docs-end theme-border-subtle-variables\n\n// Characters which are escaped by the escape-svg function\n$escaped-characters: (\n (\"<\", \"%3c\"),\n (\">\", \"%3e\"),\n (\"#\", \"%23\"),\n (\"(\", \"%28\"),\n (\")\", \"%29\"),\n) !default;\n\n// Options\n//\n// Quickly modify global styling by enabling or disabling optional features.\n\n$enable-caret: true !default;\n$enable-rounded: true !default;\n$enable-shadows: false !default;\n$enable-gradients: false !default;\n$enable-transitions: true !default;\n$enable-reduced-motion: true !default;\n$enable-smooth-scroll: true !default;\n$enable-grid-classes: true !default;\n$enable-container-classes: true !default;\n$enable-cssgrid: false !default;\n$enable-button-pointers: true !default;\n$enable-rfs: true !default;\n$enable-validation-icons: true !default;\n$enable-negative-margins: false !default;\n$enable-deprecation-messages: true !default;\n$enable-important-utilities: true !default;\n\n$enable-dark-mode: true !default;\n$color-mode-type: data !default; // `data` or `media-query`\n\n// Prefix for :root CSS variables\n\n$variable-prefix: bs- !default; // Deprecated in v5.2.0 for the shorter `$prefix`\n$prefix: $variable-prefix !default;\n\n// Gradient\n//\n// The gradient which is added to components if `$enable-gradients` is `true`\n// This gradient is also added to elements with `.bg-gradient`\n// scss-docs-start variable-gradient\n$gradient: linear-gradient(180deg, rgba($white, .15), rgba($white, 0)) !default;\n// scss-docs-end variable-gradient\n\n// Spacing\n//\n// Control the default styling of most Bootstrap elements by modifying these\n// variables. Mostly focused on spacing.\n// You can add more entries to the $spacers map, should you need more variation.\n\n// scss-docs-start spacer-variables-maps\n$spacer: 1rem !default;\n$spacers: (\n 0: 0,\n 1: $spacer * .25,\n 2: $spacer * .5,\n 3: $spacer,\n 4: $spacer * 1.5,\n 5: $spacer * 3,\n) !default;\n// scss-docs-end spacer-variables-maps\n\n// Position\n//\n// Define the edge positioning anchors of the position utilities.\n\n// scss-docs-start position-map\n$position-values: (\n 0: 0,\n 50: 50%,\n 100: 100%\n) !default;\n// scss-docs-end position-map\n\n// Body\n//\n// Settings for the `` element.\n\n$body-text-align: null !default;\n$body-color: $gray-900 !default;\n$body-bg: $white !default;\n\n$body-secondary-color: rgba($body-color, .75) !default;\n$body-secondary-bg: $gray-200 !default;\n\n$body-tertiary-color: rgba($body-color, .5) !default;\n$body-tertiary-bg: $gray-100 !default;\n\n$body-emphasis-color: $black !default;\n\n// Links\n//\n// Style anchor elements.\n\n$link-color: $primary !default;\n$link-decoration: underline !default;\n$link-shade-percentage: 20% !default;\n$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;\n$link-hover-decoration: null !default;\n\n$stretched-link-pseudo-element: after !default;\n$stretched-link-z-index: 1 !default;\n\n// Icon links\n// scss-docs-start icon-link-variables\n$icon-link-gap: .375rem !default;\n$icon-link-underline-offset: .25em !default;\n$icon-link-icon-size: 1em !default;\n$icon-link-icon-transition: .2s ease-in-out transform !default;\n$icon-link-icon-transform: translate3d(.25em, 0, 0) !default;\n// scss-docs-end icon-link-variables\n\n// Paragraphs\n//\n// Style p element.\n\n$paragraph-margin-bottom: 1rem !default;\n\n\n// Grid breakpoints\n//\n// Define the minimum dimensions at which your layout will change,\n// adapting to different screen sizes, for use in media queries.\n\n// scss-docs-start grid-breakpoints\n$grid-breakpoints: (\n xs: 0,\n sm: 576px,\n md: 768px,\n lg: 992px,\n xl: 1200px,\n xxl: 1400px\n) !default;\n// scss-docs-end grid-breakpoints\n\n@include _assert-ascending($grid-breakpoints, \"$grid-breakpoints\");\n@include _assert-starts-at-zero($grid-breakpoints, \"$grid-breakpoints\");\n\n\n// Grid containers\n//\n// Define the maximum width of `.container` for different screen sizes.\n\n// scss-docs-start container-max-widths\n$container-max-widths: (\n sm: 540px,\n md: 720px,\n lg: 960px,\n xl: 1140px,\n xxl: 1320px\n) !default;\n// scss-docs-end container-max-widths\n\n@include _assert-ascending($container-max-widths, \"$container-max-widths\");\n\n\n// Grid columns\n//\n// Set the number of columns and specify the width of the gutters.\n\n$grid-columns: 12 !default;\n$grid-gutter-width: 1.5rem !default;\n$grid-row-columns: 6 !default;\n\n// Container padding\n\n$container-padding-x: $grid-gutter-width !default;\n\n\n// Components\n//\n// Define common padding and border radius sizes and more.\n\n// scss-docs-start border-variables\n$border-width: 1px !default;\n$border-widths: (\n 1: 1px,\n 2: 2px,\n 3: 3px,\n 4: 4px,\n 5: 5px\n) !default;\n$border-style: solid !default;\n$border-color: $gray-300 !default;\n$border-color-translucent: rgba($black, .175) !default;\n// scss-docs-end border-variables\n\n// scss-docs-start border-radius-variables\n$border-radius: .375rem !default;\n$border-radius-sm: .25rem !default;\n$border-radius-lg: .5rem !default;\n$border-radius-xl: 1rem !default;\n$border-radius-xxl: 2rem !default;\n$border-radius-pill: 50rem !default;\n// scss-docs-end border-radius-variables\n// fusv-disable\n$border-radius-2xl: $border-radius-xxl !default; // Deprecated in v5.3.0\n// fusv-enable\n\n// scss-docs-start box-shadow-variables\n$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;\n$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;\n$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;\n$box-shadow-inset: inset 0 1px 2px rgba($black, .075) !default;\n// scss-docs-end box-shadow-variables\n\n$component-active-color: $white !default;\n$component-active-bg: $primary !default;\n\n// scss-docs-start focus-ring-variables\n$focus-ring-width: .25rem !default;\n$focus-ring-opacity: .25 !default;\n$focus-ring-color: rgba($primary, $focus-ring-opacity) !default;\n$focus-ring-blur: 0 !default;\n$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;\n// scss-docs-end focus-ring-variables\n\n// scss-docs-start caret-variables\n$caret-width: .3em !default;\n$caret-vertical-align: $caret-width * .85 !default;\n$caret-spacing: $caret-width * .85 !default;\n// scss-docs-end caret-variables\n\n$transition-base: all .2s ease-in-out !default;\n$transition-fade: opacity .15s linear !default;\n// scss-docs-start collapse-transition\n$transition-collapse: height .35s ease !default;\n$transition-collapse-width: width .35s ease !default;\n// scss-docs-end collapse-transition\n\n// stylelint-disable function-disallowed-list\n// scss-docs-start aspect-ratios\n$aspect-ratios: (\n \"1x1\": 100%,\n \"4x3\": calc(3 / 4 * 100%),\n \"16x9\": calc(9 / 16 * 100%),\n \"21x9\": calc(9 / 21 * 100%)\n) !default;\n// scss-docs-end aspect-ratios\n// stylelint-enable function-disallowed-list\n\n// Typography\n//\n// Font, line-height, and color for body text, headings, and more.\n\n// scss-docs-start font-variables\n// stylelint-disable value-keyword-case\n$font-family-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\" !default;\n$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !default;\n// stylelint-enable value-keyword-case\n$font-family-base: var(--#{$prefix}font-sans-serif) !default;\n$font-family-code: var(--#{$prefix}font-monospace) !default;\n\n// $font-size-root affects the value of `rem`, which is used for as well font sizes, paddings, and margins\n// $font-size-base affects the font size of the body text\n$font-size-root: null !default;\n$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`\n$font-size-sm: $font-size-base * .875 !default;\n$font-size-lg: $font-size-base * 1.25 !default;\n\n$font-weight-lighter: lighter !default;\n$font-weight-light: 300 !default;\n$font-weight-normal: 400 !default;\n$font-weight-medium: 500 !default;\n$font-weight-semibold: 600 !default;\n$font-weight-bold: 700 !default;\n$font-weight-bolder: bolder !default;\n\n$font-weight-base: $font-weight-normal !default;\n\n$line-height-base: 1.5 !default;\n$line-height-sm: 1.25 !default;\n$line-height-lg: 2 !default;\n\n$h1-font-size: $font-size-base * 2.5 !default;\n$h2-font-size: $font-size-base * 2 !default;\n$h3-font-size: $font-size-base * 1.75 !default;\n$h4-font-size: $font-size-base * 1.5 !default;\n$h5-font-size: $font-size-base * 1.25 !default;\n$h6-font-size: $font-size-base !default;\n// scss-docs-end font-variables\n\n// scss-docs-start font-sizes\n$font-sizes: (\n 1: $h1-font-size,\n 2: $h2-font-size,\n 3: $h3-font-size,\n 4: $h4-font-size,\n 5: $h5-font-size,\n 6: $h6-font-size\n) !default;\n// scss-docs-end font-sizes\n\n// scss-docs-start headings-variables\n$headings-margin-bottom: $spacer * .5 !default;\n$headings-font-family: null !default;\n$headings-font-style: null !default;\n$headings-font-weight: 500 !default;\n$headings-line-height: 1.2 !default;\n$headings-color: inherit !default;\n// scss-docs-end headings-variables\n\n// scss-docs-start display-headings\n$display-font-sizes: (\n 1: 5rem,\n 2: 4.5rem,\n 3: 4rem,\n 4: 3.5rem,\n 5: 3rem,\n 6: 2.5rem\n) !default;\n\n$display-font-family: null !default;\n$display-font-style: null !default;\n$display-font-weight: 300 !default;\n$display-line-height: $headings-line-height !default;\n// scss-docs-end display-headings\n\n// scss-docs-start type-variables\n$lead-font-size: $font-size-base * 1.25 !default;\n$lead-font-weight: 300 !default;\n\n$small-font-size: .875em !default;\n\n$sub-sup-font-size: .75em !default;\n\n// fusv-disable\n$text-muted: var(--#{$prefix}secondary-color) !default; // Deprecated in 5.3.0\n// fusv-enable\n\n$initialism-font-size: $small-font-size !default;\n\n$blockquote-margin-y: $spacer !default;\n$blockquote-font-size: $font-size-base * 1.25 !default;\n$blockquote-footer-color: $gray-600 !default;\n$blockquote-footer-font-size: $small-font-size !default;\n\n$hr-margin-y: $spacer !default;\n$hr-color: inherit !default;\n\n// fusv-disable\n$hr-bg-color: null !default; // Deprecated in v5.2.0\n$hr-height: null !default; // Deprecated in v5.2.0\n// fusv-enable\n\n$hr-border-color: null !default; // Allows for inherited colors\n$hr-border-width: var(--#{$prefix}border-width) !default;\n$hr-opacity: .25 !default;\n\n// scss-docs-start vr-variables\n$vr-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end vr-variables\n\n$legend-margin-bottom: .5rem !default;\n$legend-font-size: 1.5rem !default;\n$legend-font-weight: null !default;\n\n$dt-font-weight: $font-weight-bold !default;\n\n$list-inline-padding: .5rem !default;\n\n$mark-padding: .1875em !default;\n$mark-color: $body-color !default;\n$mark-bg: $yellow-100 !default;\n// scss-docs-end type-variables\n\n\n// Tables\n//\n// Customizes the `.table` component with basic values, each used across all table variations.\n\n// scss-docs-start table-variables\n$table-cell-padding-y: .5rem !default;\n$table-cell-padding-x: .5rem !default;\n$table-cell-padding-y-sm: .25rem !default;\n$table-cell-padding-x-sm: .25rem !default;\n\n$table-cell-vertical-align: top !default;\n\n$table-color: var(--#{$prefix}emphasis-color) !default;\n$table-bg: var(--#{$prefix}body-bg) !default;\n$table-accent-bg: transparent !default;\n\n$table-th-font-weight: null !default;\n\n$table-striped-color: $table-color !default;\n$table-striped-bg-factor: .05 !default;\n$table-striped-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-striped-bg-factor) !default;\n\n$table-active-color: $table-color !default;\n$table-active-bg-factor: .1 !default;\n$table-active-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-active-bg-factor) !default;\n\n$table-hover-color: $table-color !default;\n$table-hover-bg-factor: .075 !default;\n$table-hover-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-hover-bg-factor) !default;\n\n$table-border-factor: .2 !default;\n$table-border-width: var(--#{$prefix}border-width) !default;\n$table-border-color: var(--#{$prefix}border-color) !default;\n\n$table-striped-order: odd !default;\n$table-striped-columns-order: even !default;\n\n$table-group-separator-color: currentcolor !default;\n\n$table-caption-color: var(--#{$prefix}secondary-color) !default;\n\n$table-bg-scale: -80% !default;\n// scss-docs-end table-variables\n\n// scss-docs-start table-loop\n$table-variants: (\n \"primary\": shift-color($primary, $table-bg-scale),\n \"secondary\": shift-color($secondary, $table-bg-scale),\n \"success\": shift-color($success, $table-bg-scale),\n \"info\": shift-color($info, $table-bg-scale),\n \"warning\": shift-color($warning, $table-bg-scale),\n \"danger\": shift-color($danger, $table-bg-scale),\n \"light\": $light,\n \"dark\": $dark,\n) !default;\n// scss-docs-end table-loop\n\n\n// Buttons + Forms\n//\n// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.\n\n// scss-docs-start input-btn-variables\n$input-btn-padding-y: .375rem !default;\n$input-btn-padding-x: .75rem !default;\n$input-btn-font-family: null !default;\n$input-btn-font-size: $font-size-base !default;\n$input-btn-line-height: $line-height-base !default;\n\n$input-btn-focus-width: $focus-ring-width !default;\n$input-btn-focus-color-opacity: $focus-ring-opacity !default;\n$input-btn-focus-color: $focus-ring-color !default;\n$input-btn-focus-blur: $focus-ring-blur !default;\n$input-btn-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$input-btn-padding-y-sm: .25rem !default;\n$input-btn-padding-x-sm: .5rem !default;\n$input-btn-font-size-sm: $font-size-sm !default;\n\n$input-btn-padding-y-lg: .5rem !default;\n$input-btn-padding-x-lg: 1rem !default;\n$input-btn-font-size-lg: $font-size-lg !default;\n\n$input-btn-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end input-btn-variables\n\n\n// Buttons\n//\n// For each of Bootstrap's buttons, define text, background, and border color.\n\n// scss-docs-start btn-variables\n$btn-color: var(--#{$prefix}body-color) !default;\n$btn-padding-y: $input-btn-padding-y !default;\n$btn-padding-x: $input-btn-padding-x !default;\n$btn-font-family: $input-btn-font-family !default;\n$btn-font-size: $input-btn-font-size !default;\n$btn-line-height: $input-btn-line-height !default;\n$btn-white-space: null !default; // Set to `nowrap` to prevent text wrapping\n\n$btn-padding-y-sm: $input-btn-padding-y-sm !default;\n$btn-padding-x-sm: $input-btn-padding-x-sm !default;\n$btn-font-size-sm: $input-btn-font-size-sm !default;\n\n$btn-padding-y-lg: $input-btn-padding-y-lg !default;\n$btn-padding-x-lg: $input-btn-padding-x-lg !default;\n$btn-font-size-lg: $input-btn-font-size-lg !default;\n\n$btn-border-width: $input-btn-border-width !default;\n\n$btn-font-weight: $font-weight-normal !default;\n$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;\n$btn-focus-width: $input-btn-focus-width !default;\n$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$btn-disabled-opacity: .65 !default;\n$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;\n\n$btn-link-color: var(--#{$prefix}link-color) !default;\n$btn-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$btn-link-disabled-color: $gray-600 !default;\n$btn-link-focus-shadow-rgb: to-rgb(mix(color-contrast($link-color), $link-color, 15%)) !default;\n\n// Allows for customizing button radius independently from global border radius\n$btn-border-radius: var(--#{$prefix}border-radius) !default;\n$btn-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$btn-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$btn-hover-bg-shade-amount: 15% !default;\n$btn-hover-bg-tint-amount: 15% !default;\n$btn-hover-border-shade-amount: 20% !default;\n$btn-hover-border-tint-amount: 10% !default;\n$btn-active-bg-shade-amount: 20% !default;\n$btn-active-bg-tint-amount: 20% !default;\n$btn-active-border-shade-amount: 25% !default;\n$btn-active-border-tint-amount: 10% !default;\n// scss-docs-end btn-variables\n\n\n// Forms\n\n// scss-docs-start form-text-variables\n$form-text-margin-top: .25rem !default;\n$form-text-font-size: $small-font-size !default;\n$form-text-font-style: null !default;\n$form-text-font-weight: null !default;\n$form-text-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end form-text-variables\n\n// scss-docs-start form-label-variables\n$form-label-margin-bottom: .5rem !default;\n$form-label-font-size: null !default;\n$form-label-font-style: null !default;\n$form-label-font-weight: null !default;\n$form-label-color: null !default;\n// scss-docs-end form-label-variables\n\n// scss-docs-start form-input-variables\n$input-padding-y: $input-btn-padding-y !default;\n$input-padding-x: $input-btn-padding-x !default;\n$input-font-family: $input-btn-font-family !default;\n$input-font-size: $input-btn-font-size !default;\n$input-font-weight: $font-weight-base !default;\n$input-line-height: $input-btn-line-height !default;\n\n$input-padding-y-sm: $input-btn-padding-y-sm !default;\n$input-padding-x-sm: $input-btn-padding-x-sm !default;\n$input-font-size-sm: $input-btn-font-size-sm !default;\n\n$input-padding-y-lg: $input-btn-padding-y-lg !default;\n$input-padding-x-lg: $input-btn-padding-x-lg !default;\n$input-font-size-lg: $input-btn-font-size-lg !default;\n\n$input-bg: var(--#{$prefix}body-bg) !default;\n$input-disabled-color: null !default;\n$input-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$input-disabled-border-color: null !default;\n\n$input-color: var(--#{$prefix}body-color) !default;\n$input-border-color: var(--#{$prefix}border-color) !default;\n$input-border-width: $input-btn-border-width !default;\n$input-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$input-border-radius: var(--#{$prefix}border-radius) !default;\n$input-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$input-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$input-focus-bg: $input-bg !default;\n$input-focus-border-color: tint-color($component-active-bg, 50%) !default;\n$input-focus-color: $input-color !default;\n$input-focus-width: $input-btn-focus-width !default;\n$input-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$input-placeholder-color: var(--#{$prefix}secondary-color) !default;\n$input-plaintext-color: var(--#{$prefix}body-color) !default;\n\n$input-height-border: calc(#{$input-border-width} * 2) !default; // stylelint-disable-line function-disallowed-list\n\n$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2) !default;\n$input-height-inner-half: add($input-line-height * .5em, $input-padding-y) !default;\n$input-height-inner-quarter: add($input-line-height * .25em, $input-padding-y * .5) !default;\n\n$input-height: add($input-line-height * 1em, add($input-padding-y * 2, $input-height-border, false)) !default;\n$input-height-sm: add($input-line-height * 1em, add($input-padding-y-sm * 2, $input-height-border, false)) !default;\n$input-height-lg: add($input-line-height * 1em, add($input-padding-y-lg * 2, $input-height-border, false)) !default;\n\n$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$form-color-width: 3rem !default;\n// scss-docs-end form-input-variables\n\n// scss-docs-start form-check-variables\n$form-check-input-width: 1em !default;\n$form-check-min-height: $font-size-base * $line-height-base !default;\n$form-check-padding-start: $form-check-input-width + .5em !default;\n$form-check-margin-bottom: .125rem !default;\n$form-check-label-color: null !default;\n$form-check-label-cursor: null !default;\n$form-check-transition: null !default;\n\n$form-check-input-active-filter: brightness(90%) !default;\n\n$form-check-input-bg: $input-bg !default;\n$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}border-color) !default;\n$form-check-input-border-radius: .25em !default;\n$form-check-radio-border-radius: 50% !default;\n$form-check-input-focus-border: $input-focus-border-color !default;\n$form-check-input-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$form-check-input-checked-color: $component-active-color !default;\n$form-check-input-checked-bg-color: $component-active-bg !default;\n$form-check-input-checked-border-color: $form-check-input-checked-bg-color !default;\n$form-check-input-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-check-radio-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-indeterminate-color: $component-active-color !default;\n$form-check-input-indeterminate-bg-color: $component-active-bg !default;\n$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color !default;\n$form-check-input-indeterminate-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-disabled-opacity: .5 !default;\n$form-check-label-disabled-opacity: $form-check-input-disabled-opacity !default;\n$form-check-btn-check-disabled-opacity: $btn-disabled-opacity !default;\n\n$form-check-inline-margin-end: 1rem !default;\n// scss-docs-end form-check-variables\n\n// scss-docs-start form-switch-variables\n$form-switch-color: rgba($black, .25) !default;\n$form-switch-width: 2em !default;\n$form-switch-padding-start: $form-switch-width + .5em !default;\n$form-switch-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-border-radius: $form-switch-width !default;\n$form-switch-transition: background-position .15s ease-in-out !default;\n\n$form-switch-focus-color: $input-focus-border-color !default;\n$form-switch-focus-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-switch-checked-color: $component-active-color !default;\n$form-switch-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-checked-bg-position: right center !default;\n// scss-docs-end form-switch-variables\n\n// scss-docs-start input-group-variables\n$input-group-addon-padding-y: $input-padding-y !default;\n$input-group-addon-padding-x: $input-padding-x !default;\n$input-group-addon-font-weight: $input-font-weight !default;\n$input-group-addon-color: $input-color !default;\n$input-group-addon-bg: var(--#{$prefix}tertiary-bg) !default;\n$input-group-addon-border-color: $input-border-color !default;\n// scss-docs-end input-group-variables\n\n// scss-docs-start form-select-variables\n$form-select-padding-y: $input-padding-y !default;\n$form-select-padding-x: $input-padding-x !default;\n$form-select-font-family: $input-font-family !default;\n$form-select-font-size: $input-font-size !default;\n$form-select-indicator-padding: $form-select-padding-x * 3 !default; // Extra padding for background-image\n$form-select-font-weight: $input-font-weight !default;\n$form-select-line-height: $input-line-height !default;\n$form-select-color: $input-color !default;\n$form-select-bg: $input-bg !default;\n$form-select-disabled-color: null !default;\n$form-select-disabled-bg: $input-disabled-bg !default;\n$form-select-disabled-border-color: $input-disabled-border-color !default;\n$form-select-bg-position: right $form-select-padding-x center !default;\n$form-select-bg-size: 16px 12px !default; // In pixels because image dimensions\n$form-select-indicator-color: $gray-800 !default;\n$form-select-indicator: url(\"data:image/svg+xml,\") !default;\n\n$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + $form-select-indicator-padding !default;\n$form-select-feedback-icon-position: center right $form-select-indicator-padding !default;\n$form-select-feedback-icon-size: $input-height-inner-half $input-height-inner-half !default;\n\n$form-select-border-width: $input-border-width !default;\n$form-select-border-color: $input-border-color !default;\n$form-select-border-radius: $input-border-radius !default;\n$form-select-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-select-focus-border-color: $input-focus-border-color !default;\n$form-select-focus-width: $input-focus-width !default;\n$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width $input-btn-focus-color !default;\n\n$form-select-padding-y-sm: $input-padding-y-sm !default;\n$form-select-padding-x-sm: $input-padding-x-sm !default;\n$form-select-font-size-sm: $input-font-size-sm !default;\n$form-select-border-radius-sm: $input-border-radius-sm !default;\n\n$form-select-padding-y-lg: $input-padding-y-lg !default;\n$form-select-padding-x-lg: $input-padding-x-lg !default;\n$form-select-font-size-lg: $input-font-size-lg !default;\n$form-select-border-radius-lg: $input-border-radius-lg !default;\n\n$form-select-transition: $input-transition !default;\n// scss-docs-end form-select-variables\n\n// scss-docs-start form-range-variables\n$form-range-track-width: 100% !default;\n$form-range-track-height: .5rem !default;\n$form-range-track-cursor: pointer !default;\n$form-range-track-bg: var(--#{$prefix}secondary-bg) !default;\n$form-range-track-border-radius: 1rem !default;\n$form-range-track-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-range-thumb-width: 1rem !default;\n$form-range-thumb-height: $form-range-thumb-width !default;\n$form-range-thumb-bg: $component-active-bg !default;\n$form-range-thumb-border: 0 !default;\n$form-range-thumb-border-radius: 1rem !default;\n$form-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;\n$form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-focus-box-shadow !default;\n$form-range-thumb-focus-box-shadow-width: $input-focus-width !default; // For focus box shadow issue in Edge\n$form-range-thumb-active-bg: tint-color($component-active-bg, 70%) !default;\n$form-range-thumb-disabled-bg: var(--#{$prefix}secondary-color) !default;\n$form-range-thumb-transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n// scss-docs-end form-range-variables\n\n// scss-docs-start form-file-variables\n$form-file-button-color: $input-color !default;\n$form-file-button-bg: var(--#{$prefix}tertiary-bg) !default;\n$form-file-button-hover-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end form-file-variables\n\n// scss-docs-start form-floating-variables\n$form-floating-height: add(3.5rem, $input-height-border) !default;\n$form-floating-line-height: 1.25 !default;\n$form-floating-padding-x: $input-padding-x !default;\n$form-floating-padding-y: 1rem !default;\n$form-floating-input-padding-t: 1.625rem !default;\n$form-floating-input-padding-b: .625rem !default;\n$form-floating-label-height: 1.5em !default;\n$form-floating-label-opacity: .65 !default;\n$form-floating-label-transform: scale(.85) translateY(-.5rem) translateX(.15rem) !default;\n$form-floating-label-disabled-color: $gray-600 !default;\n$form-floating-transition: opacity .1s ease-in-out, transform .1s ease-in-out !default;\n// scss-docs-end form-floating-variables\n\n// Form validation\n\n// scss-docs-start form-feedback-variables\n$form-feedback-margin-top: $form-text-margin-top !default;\n$form-feedback-font-size: $form-text-font-size !default;\n$form-feedback-font-style: $form-text-font-style !default;\n$form-feedback-valid-color: $success !default;\n$form-feedback-invalid-color: $danger !default;\n\n$form-feedback-icon-valid-color: $form-feedback-valid-color !default;\n$form-feedback-icon-valid: url(\"data:image/svg+xml,\") !default;\n$form-feedback-icon-invalid-color: $form-feedback-invalid-color !default;\n$form-feedback-icon-invalid: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end form-feedback-variables\n\n// scss-docs-start form-validation-colors\n$form-valid-color: $form-feedback-valid-color !default;\n$form-valid-border-color: $form-feedback-valid-color !default;\n$form-invalid-color: $form-feedback-invalid-color !default;\n$form-invalid-border-color: $form-feedback-invalid-color !default;\n// scss-docs-end form-validation-colors\n\n// scss-docs-start form-validation-states\n$form-validation-states: (\n \"valid\": (\n \"color\": var(--#{$prefix}form-valid-color),\n \"icon\": $form-feedback-icon-valid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}success),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-valid-border-color),\n ),\n \"invalid\": (\n \"color\": var(--#{$prefix}form-invalid-color),\n \"icon\": $form-feedback-icon-invalid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}danger),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-invalid-border-color),\n )\n) !default;\n// scss-docs-end form-validation-states\n\n// Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n\n// scss-docs-start zindex-stack\n$zindex-dropdown: 1000 !default;\n$zindex-sticky: 1020 !default;\n$zindex-fixed: 1030 !default;\n$zindex-offcanvas-backdrop: 1040 !default;\n$zindex-offcanvas: 1045 !default;\n$zindex-modal-backdrop: 1050 !default;\n$zindex-modal: 1055 !default;\n$zindex-popover: 1070 !default;\n$zindex-tooltip: 1080 !default;\n$zindex-toast: 1090 !default;\n// scss-docs-end zindex-stack\n\n// scss-docs-start zindex-levels-map\n$zindex-levels: (\n n1: -1,\n 0: 0,\n 1: 1,\n 2: 2,\n 3: 3\n) !default;\n// scss-docs-end zindex-levels-map\n\n\n// Navs\n\n// scss-docs-start nav-variables\n$nav-link-padding-y: .5rem !default;\n$nav-link-padding-x: 1rem !default;\n$nav-link-font-size: null !default;\n$nav-link-font-weight: null !default;\n$nav-link-color: var(--#{$prefix}link-color) !default;\n$nav-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$nav-link-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out !default;\n$nav-link-disabled-color: var(--#{$prefix}secondary-color) !default;\n$nav-link-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$nav-tabs-border-color: var(--#{$prefix}border-color) !default;\n$nav-tabs-border-width: var(--#{$prefix}border-width) !default;\n$nav-tabs-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-tabs-link-hover-border-color: var(--#{$prefix}secondary-bg) var(--#{$prefix}secondary-bg) $nav-tabs-border-color !default;\n$nav-tabs-link-active-color: var(--#{$prefix}emphasis-color) !default;\n$nav-tabs-link-active-bg: var(--#{$prefix}body-bg) !default;\n$nav-tabs-link-active-border-color: var(--#{$prefix}border-color) var(--#{$prefix}border-color) $nav-tabs-link-active-bg !default;\n\n$nav-pills-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-pills-link-active-color: $component-active-color !default;\n$nav-pills-link-active-bg: $component-active-bg !default;\n\n$nav-underline-gap: 1rem !default;\n$nav-underline-border-width: .125rem !default;\n$nav-underline-link-active-color: var(--#{$prefix}emphasis-color) !default;\n// scss-docs-end nav-variables\n\n\n// Navbar\n\n// scss-docs-start navbar-variables\n$navbar-padding-y: $spacer * .5 !default;\n$navbar-padding-x: null !default;\n\n$navbar-nav-link-padding-x: .5rem !default;\n\n$navbar-brand-font-size: $font-size-lg !default;\n// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link\n$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default;\n$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;\n$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default;\n$navbar-brand-margin-end: 1rem !default;\n\n$navbar-toggler-padding-y: .25rem !default;\n$navbar-toggler-padding-x: .75rem !default;\n$navbar-toggler-font-size: $font-size-lg !default;\n$navbar-toggler-border-radius: $btn-border-radius !default;\n$navbar-toggler-focus-width: $btn-focus-width !default;\n$navbar-toggler-transition: box-shadow .15s ease-in-out !default;\n\n$navbar-light-color: rgba(var(--#{$prefix}emphasis-color-rgb), .65) !default;\n$navbar-light-hover-color: rgba(var(--#{$prefix}emphasis-color-rgb), .8) !default;\n$navbar-light-active-color: rgba(var(--#{$prefix}emphasis-color-rgb), 1) !default;\n$navbar-light-disabled-color: rgba(var(--#{$prefix}emphasis-color-rgb), .3) !default;\n$navbar-light-icon-color: rgba($body-color, .75) !default;\n$navbar-light-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-light-toggler-border-color: rgba(var(--#{$prefix}emphasis-color-rgb), .15) !default;\n$navbar-light-brand-color: $navbar-light-active-color !default;\n$navbar-light-brand-hover-color: $navbar-light-active-color !default;\n// scss-docs-end navbar-variables\n\n// scss-docs-start navbar-dark-variables\n$navbar-dark-color: rgba($white, .55) !default;\n$navbar-dark-hover-color: rgba($white, .75) !default;\n$navbar-dark-active-color: $white !default;\n$navbar-dark-disabled-color: rgba($white, .25) !default;\n$navbar-dark-icon-color: $navbar-dark-color !default;\n$navbar-dark-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-dark-toggler-border-color: rgba($white, .1) !default;\n$navbar-dark-brand-color: $navbar-dark-active-color !default;\n$navbar-dark-brand-hover-color: $navbar-dark-active-color !default;\n// scss-docs-end navbar-dark-variables\n\n\n// Dropdowns\n//\n// Dropdown menu container and contents.\n\n// scss-docs-start dropdown-variables\n$dropdown-min-width: 10rem !default;\n$dropdown-padding-x: 0 !default;\n$dropdown-padding-y: .5rem !default;\n$dropdown-spacer: .125rem !default;\n$dropdown-font-size: $font-size-base !default;\n$dropdown-color: var(--#{$prefix}body-color) !default;\n$dropdown-bg: var(--#{$prefix}body-bg) !default;\n$dropdown-border-color: var(--#{$prefix}border-color-translucent) !default;\n$dropdown-border-radius: var(--#{$prefix}border-radius) !default;\n$dropdown-border-width: var(--#{$prefix}border-width) !default;\n$dropdown-inner-border-radius: calc(#{$dropdown-border-radius} - #{$dropdown-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$dropdown-divider-bg: $dropdown-border-color !default;\n$dropdown-divider-margin-y: $spacer * .5 !default;\n$dropdown-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$dropdown-link-color: var(--#{$prefix}body-color) !default;\n$dropdown-link-hover-color: $dropdown-link-color !default;\n$dropdown-link-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n\n$dropdown-link-active-color: $component-active-color !default;\n$dropdown-link-active-bg: $component-active-bg !default;\n\n$dropdown-link-disabled-color: var(--#{$prefix}tertiary-color) !default;\n\n$dropdown-item-padding-y: $spacer * .25 !default;\n$dropdown-item-padding-x: $spacer !default;\n\n$dropdown-header-color: $gray-600 !default;\n$dropdown-header-padding-x: $dropdown-item-padding-x !default;\n$dropdown-header-padding-y: $dropdown-padding-y !default;\n// fusv-disable\n$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x !default; // Deprecated in v5.2.0\n// fusv-enable\n// scss-docs-end dropdown-variables\n\n// scss-docs-start dropdown-dark-variables\n$dropdown-dark-color: $gray-300 !default;\n$dropdown-dark-bg: $gray-800 !default;\n$dropdown-dark-border-color: $dropdown-border-color !default;\n$dropdown-dark-divider-bg: $dropdown-divider-bg !default;\n$dropdown-dark-box-shadow: null !default;\n$dropdown-dark-link-color: $dropdown-dark-color !default;\n$dropdown-dark-link-hover-color: $white !default;\n$dropdown-dark-link-hover-bg: rgba($white, .15) !default;\n$dropdown-dark-link-active-color: $dropdown-link-active-color !default;\n$dropdown-dark-link-active-bg: $dropdown-link-active-bg !default;\n$dropdown-dark-link-disabled-color: $gray-500 !default;\n$dropdown-dark-header-color: $gray-500 !default;\n// scss-docs-end dropdown-dark-variables\n\n\n// Pagination\n\n// scss-docs-start pagination-variables\n$pagination-padding-y: .375rem !default;\n$pagination-padding-x: .75rem !default;\n$pagination-padding-y-sm: .25rem !default;\n$pagination-padding-x-sm: .5rem !default;\n$pagination-padding-y-lg: .75rem !default;\n$pagination-padding-x-lg: 1.5rem !default;\n\n$pagination-font-size: $font-size-base !default;\n\n$pagination-color: var(--#{$prefix}link-color) !default;\n$pagination-bg: var(--#{$prefix}body-bg) !default;\n$pagination-border-radius: var(--#{$prefix}border-radius) !default;\n$pagination-border-width: var(--#{$prefix}border-width) !default;\n$pagination-margin-start: calc(#{$pagination-border-width} * -1) !default; // stylelint-disable-line function-disallowed-list\n$pagination-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-focus-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-focus-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-focus-box-shadow: $focus-ring-box-shadow !default;\n$pagination-focus-outline: 0 !default;\n\n$pagination-hover-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$pagination-hover-border-color: var(--#{$prefix}border-color) !default; // Todo in v6: remove this?\n\n$pagination-active-color: $component-active-color !default;\n$pagination-active-bg: $component-active-bg !default;\n$pagination-active-border-color: $component-active-bg !default;\n\n$pagination-disabled-color: var(--#{$prefix}secondary-color) !default;\n$pagination-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-disabled-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$pagination-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$pagination-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n// scss-docs-end pagination-variables\n\n\n// Placeholders\n\n// scss-docs-start placeholders\n$placeholder-opacity-max: .5 !default;\n$placeholder-opacity-min: .2 !default;\n// scss-docs-end placeholders\n\n// Cards\n\n// scss-docs-start card-variables\n$card-spacer-y: $spacer !default;\n$card-spacer-x: $spacer !default;\n$card-title-spacer-y: $spacer * .5 !default;\n$card-title-color: null !default;\n$card-subtitle-color: null !default;\n$card-border-width: var(--#{$prefix}border-width) !default;\n$card-border-color: var(--#{$prefix}border-color-translucent) !default;\n$card-border-radius: var(--#{$prefix}border-radius) !default;\n$card-box-shadow: null !default;\n$card-inner-border-radius: subtract($card-border-radius, $card-border-width) !default;\n$card-cap-padding-y: $card-spacer-y * .5 !default;\n$card-cap-padding-x: $card-spacer-x !default;\n$card-cap-bg: rgba(var(--#{$prefix}body-color-rgb), .03) !default;\n$card-cap-color: null !default;\n$card-height: null !default;\n$card-color: null !default;\n$card-bg: var(--#{$prefix}body-bg) !default;\n$card-img-overlay-padding: $spacer !default;\n$card-group-margin: $grid-gutter-width * .5 !default;\n// scss-docs-end card-variables\n\n// Accordion\n\n// scss-docs-start accordion-variables\n$accordion-padding-y: 1rem !default;\n$accordion-padding-x: 1.25rem !default;\n$accordion-color: var(--#{$prefix}body-color) !default;\n$accordion-bg: var(--#{$prefix}body-bg) !default;\n$accordion-border-width: var(--#{$prefix}border-width) !default;\n$accordion-border-color: var(--#{$prefix}border-color) !default;\n$accordion-border-radius: var(--#{$prefix}border-radius) !default;\n$accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;\n\n$accordion-body-padding-y: $accordion-padding-y !default;\n$accordion-body-padding-x: $accordion-padding-x !default;\n\n$accordion-button-padding-y: $accordion-padding-y !default;\n$accordion-button-padding-x: $accordion-padding-x !default;\n$accordion-button-color: var(--#{$prefix}body-color) !default;\n$accordion-button-bg: var(--#{$prefix}accordion-bg) !default;\n$accordion-transition: $btn-transition, border-radius .15s ease !default;\n$accordion-button-active-bg: var(--#{$prefix}primary-bg-subtle) !default;\n$accordion-button-active-color: var(--#{$prefix}primary-text-emphasis) !default;\n\n// fusv-disable\n$accordion-button-focus-border-color: $input-focus-border-color !default; // Deprecated in v5.3.3\n// fusv-enable\n$accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;\n\n$accordion-icon-width: 1.25rem !default;\n$accordion-icon-color: $body-color !default;\n$accordion-icon-active-color: $primary-text-emphasis !default;\n$accordion-icon-transition: transform .2s ease-in-out !default;\n$accordion-icon-transform: rotate(-180deg) !default;\n\n$accordion-button-icon: url(\"data:image/svg+xml,\") !default;\n$accordion-button-active-icon: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end accordion-variables\n\n// Tooltips\n\n// scss-docs-start tooltip-variables\n$tooltip-font-size: $font-size-sm !default;\n$tooltip-max-width: 200px !default;\n$tooltip-color: var(--#{$prefix}body-bg) !default;\n$tooltip-bg: var(--#{$prefix}emphasis-color) !default;\n$tooltip-border-radius: var(--#{$prefix}border-radius) !default;\n$tooltip-opacity: .9 !default;\n$tooltip-padding-y: $spacer * .25 !default;\n$tooltip-padding-x: $spacer * .5 !default;\n$tooltip-margin: null !default; // TODO: remove this in v6\n\n$tooltip-arrow-width: .8rem !default;\n$tooltip-arrow-height: .4rem !default;\n// fusv-disable\n$tooltip-arrow-color: null !default; // Deprecated in Bootstrap 5.2.0 for CSS variables\n// fusv-enable\n// scss-docs-end tooltip-variables\n\n// Form tooltips must come after regular tooltips\n// scss-docs-start tooltip-feedback-variables\n$form-feedback-tooltip-padding-y: $tooltip-padding-y !default;\n$form-feedback-tooltip-padding-x: $tooltip-padding-x !default;\n$form-feedback-tooltip-font-size: $tooltip-font-size !default;\n$form-feedback-tooltip-line-height: null !default;\n$form-feedback-tooltip-opacity: $tooltip-opacity !default;\n$form-feedback-tooltip-border-radius: $tooltip-border-radius !default;\n// scss-docs-end tooltip-feedback-variables\n\n\n// Popovers\n\n// scss-docs-start popover-variables\n$popover-font-size: $font-size-sm !default;\n$popover-bg: var(--#{$prefix}body-bg) !default;\n$popover-max-width: 276px !default;\n$popover-border-width: var(--#{$prefix}border-width) !default;\n$popover-border-color: var(--#{$prefix}border-color-translucent) !default;\n$popover-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$popover-inner-border-radius: calc(#{$popover-border-radius} - #{$popover-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$popover-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$popover-header-font-size: $font-size-base !default;\n$popover-header-bg: var(--#{$prefix}secondary-bg) !default;\n$popover-header-color: $headings-color !default;\n$popover-header-padding-y: .5rem !default;\n$popover-header-padding-x: $spacer !default;\n\n$popover-body-color: var(--#{$prefix}body-color) !default;\n$popover-body-padding-y: $spacer !default;\n$popover-body-padding-x: $spacer !default;\n\n$popover-arrow-width: 1rem !default;\n$popover-arrow-height: .5rem !default;\n// scss-docs-end popover-variables\n\n// fusv-disable\n// Deprecated in Bootstrap 5.2.0 for CSS variables\n$popover-arrow-color: $popover-bg !default;\n$popover-arrow-outer-color: var(--#{$prefix}border-color-translucent) !default;\n// fusv-enable\n\n\n// Toasts\n\n// scss-docs-start toast-variables\n$toast-max-width: 350px !default;\n$toast-padding-x: .75rem !default;\n$toast-padding-y: .5rem !default;\n$toast-font-size: .875rem !default;\n$toast-color: null !default;\n$toast-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-border-width: var(--#{$prefix}border-width) !default;\n$toast-border-color: var(--#{$prefix}border-color-translucent) !default;\n$toast-border-radius: var(--#{$prefix}border-radius) !default;\n$toast-box-shadow: var(--#{$prefix}box-shadow) !default;\n$toast-spacing: $container-padding-x !default;\n\n$toast-header-color: var(--#{$prefix}secondary-color) !default;\n$toast-header-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-header-border-color: $toast-border-color !default;\n// scss-docs-end toast-variables\n\n\n// Badges\n\n// scss-docs-start badge-variables\n$badge-font-size: .75em !default;\n$badge-font-weight: $font-weight-bold !default;\n$badge-color: $white !default;\n$badge-padding-y: .35em !default;\n$badge-padding-x: .65em !default;\n$badge-border-radius: var(--#{$prefix}border-radius) !default;\n// scss-docs-end badge-variables\n\n\n// Modals\n\n// scss-docs-start modal-variables\n$modal-inner-padding: $spacer !default;\n\n$modal-footer-margin-between: .5rem !default;\n\n$modal-dialog-margin: .5rem !default;\n$modal-dialog-margin-y-sm-up: 1.75rem !default;\n\n$modal-title-line-height: $line-height-base !default;\n\n$modal-content-color: null !default;\n$modal-content-bg: var(--#{$prefix}body-bg) !default;\n$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default;\n$modal-content-border-width: var(--#{$prefix}border-width) !default;\n$modal-content-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$modal-content-inner-border-radius: subtract($modal-content-border-radius, $modal-content-border-width) !default;\n$modal-content-box-shadow-xs: var(--#{$prefix}box-shadow-sm) !default;\n$modal-content-box-shadow-sm-up: var(--#{$prefix}box-shadow) !default;\n\n$modal-backdrop-bg: $black !default;\n$modal-backdrop-opacity: .5 !default;\n\n$modal-header-border-color: var(--#{$prefix}border-color) !default;\n$modal-header-border-width: $modal-content-border-width !default;\n$modal-header-padding-y: $modal-inner-padding !default;\n$modal-header-padding-x: $modal-inner-padding !default;\n$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility\n\n$modal-footer-bg: null !default;\n$modal-footer-border-color: $modal-header-border-color !default;\n$modal-footer-border-width: $modal-header-border-width !default;\n\n$modal-sm: 300px !default;\n$modal-md: 500px !default;\n$modal-lg: 800px !default;\n$modal-xl: 1140px !default;\n\n$modal-fade-transform: translate(0, -50px) !default;\n$modal-show-transform: none !default;\n$modal-transition: transform .3s ease-out !default;\n$modal-scale-transform: scale(1.02) !default;\n// scss-docs-end modal-variables\n\n\n// Alerts\n//\n// Define alert colors, border radius, and padding.\n\n// scss-docs-start alert-variables\n$alert-padding-y: $spacer !default;\n$alert-padding-x: $spacer !default;\n$alert-margin-bottom: 1rem !default;\n$alert-border-radius: var(--#{$prefix}border-radius) !default;\n$alert-link-font-weight: $font-weight-bold !default;\n$alert-border-width: var(--#{$prefix}border-width) !default;\n$alert-dismissible-padding-r: $alert-padding-x * 3 !default; // 3x covers width of x plus default padding on either side\n// scss-docs-end alert-variables\n\n// fusv-disable\n$alert-bg-scale: -80% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-border-scale: -70% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-color-scale: 40% !default; // Deprecated in v5.2.0, to be removed in v6\n// fusv-enable\n\n// Progress bars\n\n// scss-docs-start progress-variables\n$progress-height: 1rem !default;\n$progress-font-size: $font-size-base * .75 !default;\n$progress-bg: var(--#{$prefix}secondary-bg) !default;\n$progress-border-radius: var(--#{$prefix}border-radius) !default;\n$progress-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n$progress-bar-color: $white !default;\n$progress-bar-bg: $primary !default;\n$progress-bar-animation-timing: 1s linear infinite !default;\n$progress-bar-transition: width .6s ease !default;\n// scss-docs-end progress-variables\n\n\n// List group\n\n// scss-docs-start list-group-variables\n$list-group-color: var(--#{$prefix}body-color) !default;\n$list-group-bg: var(--#{$prefix}body-bg) !default;\n$list-group-border-color: var(--#{$prefix}border-color) !default;\n$list-group-border-width: var(--#{$prefix}border-width) !default;\n$list-group-border-radius: var(--#{$prefix}border-radius) !default;\n\n$list-group-item-padding-y: $spacer * .5 !default;\n$list-group-item-padding-x: $spacer !default;\n// fusv-disable\n$list-group-item-bg-scale: -80% !default; // Deprecated in v5.3.0\n$list-group-item-color-scale: 40% !default; // Deprecated in v5.3.0\n// fusv-enable\n\n$list-group-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$list-group-active-color: $component-active-color !default;\n$list-group-active-bg: $component-active-bg !default;\n$list-group-active-border-color: $list-group-active-bg !default;\n\n$list-group-disabled-color: var(--#{$prefix}secondary-color) !default;\n$list-group-disabled-bg: $list-group-bg !default;\n\n$list-group-action-color: var(--#{$prefix}secondary-color) !default;\n$list-group-action-hover-color: var(--#{$prefix}emphasis-color) !default;\n\n$list-group-action-active-color: var(--#{$prefix}body-color) !default;\n$list-group-action-active-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end list-group-variables\n\n\n// Image thumbnails\n\n// scss-docs-start thumbnail-variables\n$thumbnail-padding: .25rem !default;\n$thumbnail-bg: var(--#{$prefix}body-bg) !default;\n$thumbnail-border-width: var(--#{$prefix}border-width) !default;\n$thumbnail-border-color: var(--#{$prefix}border-color) !default;\n$thumbnail-border-radius: var(--#{$prefix}border-radius) !default;\n$thumbnail-box-shadow: var(--#{$prefix}box-shadow-sm) !default;\n// scss-docs-end thumbnail-variables\n\n\n// Figures\n\n// scss-docs-start figure-variables\n$figure-caption-font-size: $small-font-size !default;\n$figure-caption-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end figure-variables\n\n\n// Breadcrumbs\n\n// scss-docs-start breadcrumb-variables\n$breadcrumb-font-size: null !default;\n$breadcrumb-padding-y: 0 !default;\n$breadcrumb-padding-x: 0 !default;\n$breadcrumb-item-padding-x: .5rem !default;\n$breadcrumb-margin-bottom: 1rem !default;\n$breadcrumb-bg: null !default;\n$breadcrumb-divider-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-active-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-divider: quote(\"/\") !default;\n$breadcrumb-divider-flipped: $breadcrumb-divider !default;\n$breadcrumb-border-radius: null !default;\n// scss-docs-end breadcrumb-variables\n\n// Carousel\n\n// scss-docs-start carousel-variables\n$carousel-control-color: $white !default;\n$carousel-control-width: 15% !default;\n$carousel-control-opacity: .5 !default;\n$carousel-control-hover-opacity: .9 !default;\n$carousel-control-transition: opacity .15s ease !default;\n\n$carousel-indicator-width: 30px !default;\n$carousel-indicator-height: 3px !default;\n$carousel-indicator-hit-area-height: 10px !default;\n$carousel-indicator-spacer: 3px !default;\n$carousel-indicator-opacity: .5 !default;\n$carousel-indicator-active-bg: $white !default;\n$carousel-indicator-active-opacity: 1 !default;\n$carousel-indicator-transition: opacity .6s ease !default;\n\n$carousel-caption-width: 70% !default;\n$carousel-caption-color: $white !default;\n$carousel-caption-padding-y: 1.25rem !default;\n$carousel-caption-spacer: 1.25rem !default;\n\n$carousel-control-icon-width: 2rem !default;\n\n$carousel-control-prev-icon-bg: url(\"data:image/svg+xml,\") !default;\n$carousel-control-next-icon-bg: url(\"data:image/svg+xml,\") !default;\n\n$carousel-transition-duration: .6s !default;\n$carousel-transition: transform $carousel-transition-duration ease-in-out !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)\n// scss-docs-end carousel-variables\n\n// scss-docs-start carousel-dark-variables\n$carousel-dark-indicator-active-bg: $black !default;\n$carousel-dark-caption-color: $black !default;\n$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default;\n// scss-docs-end carousel-dark-variables\n\n\n// Spinners\n\n// scss-docs-start spinner-variables\n$spinner-width: 2rem !default;\n$spinner-height: $spinner-width !default;\n$spinner-vertical-align: -.125em !default;\n$spinner-border-width: .25em !default;\n$spinner-animation-speed: .75s !default;\n\n$spinner-width-sm: 1rem !default;\n$spinner-height-sm: $spinner-width-sm !default;\n$spinner-border-width-sm: .2em !default;\n// scss-docs-end spinner-variables\n\n\n// Close\n\n// scss-docs-start close-variables\n$btn-close-width: 1em !default;\n$btn-close-height: $btn-close-width !default;\n$btn-close-padding-x: .25em !default;\n$btn-close-padding-y: $btn-close-padding-x !default;\n$btn-close-color: $black !default;\n$btn-close-bg: url(\"data:image/svg+xml,\") !default;\n$btn-close-focus-shadow: $focus-ring-box-shadow !default;\n$btn-close-opacity: .5 !default;\n$btn-close-hover-opacity: .75 !default;\n$btn-close-focus-opacity: 1 !default;\n$btn-close-disabled-opacity: .25 !default;\n$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default;\n// scss-docs-end close-variables\n\n\n// Offcanvas\n\n// scss-docs-start offcanvas-variables\n$offcanvas-padding-y: $modal-inner-padding !default;\n$offcanvas-padding-x: $modal-inner-padding !default;\n$offcanvas-horizontal-width: 400px !default;\n$offcanvas-vertical-height: 30vh !default;\n$offcanvas-transition-duration: .3s !default;\n$offcanvas-border-color: $modal-content-border-color !default;\n$offcanvas-border-width: $modal-content-border-width !default;\n$offcanvas-title-line-height: $modal-title-line-height !default;\n$offcanvas-bg-color: var(--#{$prefix}body-bg) !default;\n$offcanvas-color: var(--#{$prefix}body-color) !default;\n$offcanvas-box-shadow: $modal-content-box-shadow-xs !default;\n$offcanvas-backdrop-bg: $modal-backdrop-bg !default;\n$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default;\n// scss-docs-end offcanvas-variables\n\n// Code\n\n$code-font-size: $small-font-size !default;\n$code-color: $pink !default;\n\n$kbd-padding-y: .1875rem !default;\n$kbd-padding-x: .375rem !default;\n$kbd-font-size: $code-font-size !default;\n$kbd-color: var(--#{$prefix}body-bg) !default;\n$kbd-bg: var(--#{$prefix}body-color) !default;\n$nested-kbd-font-weight: null !default; // Deprecated in v5.2.0, removing in v6\n\n$pre-color: null !default;\n\n@import \"variables-dark\"; // TODO: can be removed safely in v6, only here to avoid breaking changes in v5.3\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css new file mode 100644 index 00000000..49b843b1 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}@media (min-width:576px){.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}}@media (min-width:768px){.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}}@media (min-width:992px){.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}}@media (min-width:1200px){.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}}@media (min-width:1400px){.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap-grid.min.css.map */ \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map new file mode 100644 index 00000000..a0db8b57 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","dist/css/bootstrap-grid.css","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"AACE;;;;ACKA,WCAF,iBAGA,cACA,cACA,cAHA,cADA,eCJE,cAAA,OACA,cAAA,EACA,MAAA,KACA,cAAA,8BACA,aAAA,8BACA,aAAA,KACA,YAAA,KCsDE,yBH5CE,WAAA,cACE,UAAA,OG2CJ,yBH5CE,WAAA,cAAA,cACE,UAAA,OG2CJ,yBH5CE,WAAA,cAAA,cAAA,cACE,UAAA,OG2CJ,0BH5CE,WAAA,cAAA,cAAA,cAAA,cACE,UAAA,QG2CJ,0BH5CE,WAAA,cAAA,cAAA,cAAA,cAAA,eACE,UAAA,QIhBR,MAEI,mBAAA,EAAA,mBAAA,MAAA,mBAAA,MAAA,mBAAA,MAAA,mBAAA,OAAA,oBAAA,OAKF,KCNA,cAAA,OACA,cAAA,EACA,QAAA,KACA,UAAA,KAEA,WAAA,8BACA,aAAA,+BACA,YAAA,+BDEE,OCGF,WAAA,WAIA,YAAA,EACA,MAAA,KACA,UAAA,KACA,cAAA,8BACA,aAAA,8BACA,WAAA,mBA+CI,KACE,KAAA,EAAA,EAAA,GAGF,iBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,cACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,UAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,UAxDV,YAAA,YAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,aAwDU,UAxDV,YAAA,IAwDU,WAxDV,YAAA,aAwDU,WAxDV,YAAA,aAmEM,KJ6GR,MI3GU,cAAA,EAGF,KJ6GR,MI3GU,cAAA,EAPF,KJuHR,MIrHU,cAAA,QAGF,KJuHR,MIrHU,cAAA,QAPF,KJiIR,MI/HU,cAAA,OAGF,KJiIR,MI/HU,cAAA,OAPF,KJ2IR,MIzIU,cAAA,KAGF,KJ2IR,MIzIU,cAAA,KAPF,KJqJR,MInJU,cAAA,OAGF,KJqJR,MInJU,cAAA,OAPF,KJ+JR,MI7JU,cAAA,KAGF,KJ+JR,MI7JU,cAAA,KF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QJiSN,SI/RQ,cAAA,EAGF,QJgSN,SI9RQ,cAAA,EAPF,QJySN,SIvSQ,cAAA,QAGF,QJwSN,SItSQ,cAAA,QAPF,QJiTN,SI/SQ,cAAA,OAGF,QJgTN,SI9SQ,cAAA,OAPF,QJyTN,SIvTQ,cAAA,KAGF,QJwTN,SItTQ,cAAA,KAPF,QJiUN,SI/TQ,cAAA,OAGF,QJgUN,SI9TQ,cAAA,OAPF,QJyUN,SIvUQ,cAAA,KAGF,QJwUN,SItUQ,cAAA,MF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QJ0cN,SIxcQ,cAAA,EAGF,QJycN,SIvcQ,cAAA,EAPF,QJkdN,SIhdQ,cAAA,QAGF,QJidN,SI/cQ,cAAA,QAPF,QJ0dN,SIxdQ,cAAA,OAGF,QJydN,SIvdQ,cAAA,OAPF,QJkeN,SIheQ,cAAA,KAGF,QJieN,SI/dQ,cAAA,KAPF,QJ0eN,SIxeQ,cAAA,OAGF,QJyeN,SIveQ,cAAA,OAPF,QJkfN,SIhfQ,cAAA,KAGF,QJifN,SI/eQ,cAAA,MF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QJmnBN,SIjnBQ,cAAA,EAGF,QJknBN,SIhnBQ,cAAA,EAPF,QJ2nBN,SIznBQ,cAAA,QAGF,QJ0nBN,SIxnBQ,cAAA,QAPF,QJmoBN,SIjoBQ,cAAA,OAGF,QJkoBN,SIhoBQ,cAAA,OAPF,QJ2oBN,SIzoBQ,cAAA,KAGF,QJ0oBN,SIxoBQ,cAAA,KAPF,QJmpBN,SIjpBQ,cAAA,OAGF,QJkpBN,SIhpBQ,cAAA,OAPF,QJ2pBN,SIzpBQ,cAAA,KAGF,QJ0pBN,SIxpBQ,cAAA,MF1DN,0BEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,YAAA,EAwDU,aAxDV,YAAA,YAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,aAwDU,aAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAmEM,QJ4xBN,SI1xBQ,cAAA,EAGF,QJ2xBN,SIzxBQ,cAAA,EAPF,QJoyBN,SIlyBQ,cAAA,QAGF,QJmyBN,SIjyBQ,cAAA,QAPF,QJ4yBN,SI1yBQ,cAAA,OAGF,QJ2yBN,SIzyBQ,cAAA,OAPF,QJozBN,SIlzBQ,cAAA,KAGF,QJmzBN,SIjzBQ,cAAA,KAPF,QJ4zBN,SI1zBQ,cAAA,OAGF,QJ2zBN,SIzzBQ,cAAA,OAPF,QJo0BN,SIl0BQ,cAAA,KAGF,QJm0BN,SIj0BQ,cAAA,MF1DN,0BEUE,SACE,KAAA,EAAA,EAAA,GAGF,qBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,cAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,cAxDV,YAAA,EAwDU,cAxDV,YAAA,YAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,aAwDU,cAxDV,YAAA,IAwDU,eAxDV,YAAA,aAwDU,eAxDV,YAAA,aAmEM,SJq8BN,UIn8BQ,cAAA,EAGF,SJo8BN,UIl8BQ,cAAA,EAPF,SJ68BN,UI38BQ,cAAA,QAGF,SJ48BN,UI18BQ,cAAA,QAPF,SJq9BN,UIn9BQ,cAAA,OAGF,SJo9BN,UIl9BQ,cAAA,OAPF,SJ69BN,UI39BQ,cAAA,KAGF,SJ49BN,UI19BQ,cAAA,KAPF,SJq+BN,UIn+BQ,cAAA,OAGF,SJo+BN,UIl+BQ,cAAA,OAPF,SJ6+BN,UI3+BQ,cAAA,KAGF,SJ4+BN,UI1+BQ,cAAA,MCvDF,UAOI,QAAA,iBAPJ,gBAOI,QAAA,uBAPJ,SAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,SAOI,QAAA,gBAPJ,aAOI,QAAA,oBAPJ,cAOI,QAAA,qBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,QAOI,QAAA,eAPJ,WAOI,KAAA,EAAA,EAAA,eAPJ,UAOI,eAAA,cAPJ,aAOI,eAAA,iBAPJ,kBAOI,eAAA,sBAPJ,qBAOI,eAAA,yBAPJ,aAOI,UAAA,YAPJ,aAOI,UAAA,YAPJ,eAOI,YAAA,YAPJ,eAOI,YAAA,YAPJ,WAOI,UAAA,eAPJ,aAOI,UAAA,iBAPJ,mBAOI,UAAA,uBAPJ,uBAOI,gBAAA,qBAPJ,qBAOI,gBAAA,mBAPJ,wBAOI,gBAAA,iBAPJ,yBAOI,gBAAA,wBAPJ,wBAOI,gBAAA,uBAPJ,wBAOI,gBAAA,uBAPJ,mBAOI,YAAA,qBAPJ,iBAOI,YAAA,mBAPJ,oBAOI,YAAA,iBAPJ,sBAOI,YAAA,mBAPJ,qBAOI,YAAA,kBAPJ,qBAOI,cAAA,qBAPJ,mBAOI,cAAA,mBAPJ,sBAOI,cAAA,iBAPJ,uBAOI,cAAA,wBAPJ,sBAOI,cAAA,uBAPJ,uBAOI,cAAA,kBAPJ,iBAOI,WAAA,eAPJ,kBAOI,WAAA,qBAPJ,gBAOI,WAAA,mBAPJ,mBAOI,WAAA,iBAPJ,qBAOI,WAAA,mBAPJ,oBAOI,WAAA,kBAPJ,aAOI,MAAA,aAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,KAOI,OAAA,YAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,gBAPJ,KAOI,OAAA,eAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,MAOI,aAAA,YAAA,YAAA,YAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,gBAAA,YAAA,gBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,aAAA,iBAAA,YAAA,iBAPJ,MAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,MAOI,WAAA,YAAA,cAAA,YAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,gBAAA,cAAA,gBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,YAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,gBAPJ,MAOI,WAAA,eAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,eAPJ,SAOI,WAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,SAOI,aAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,SAOI,cAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,SAOI,YAAA,eAPJ,KAOI,QAAA,YAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,gBAPJ,KAOI,QAAA,eAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,eAPJ,MAOI,cAAA,YAAA,aAAA,YAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,gBAAA,aAAA,gBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,cAAA,iBAAA,aAAA,iBAPJ,MAOI,cAAA,eAAA,aAAA,eAPJ,MAOI,YAAA,YAAA,eAAA,YAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,gBAAA,eAAA,gBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,MAOI,eAAA,YAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,gBAPJ,MAOI,eAAA,eAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eHVR,yBGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBHVR,yBGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBHVR,yBGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBHVR,0BGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,aAAA,YAAA,YAAA,YAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,gBAAA,YAAA,gBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,aAAA,iBAAA,YAAA,iBAPJ,SAOI,aAAA,eAAA,YAAA,eAPJ,YAOI,aAAA,eAAA,YAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,cAAA,YAAA,aAAA,YAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,gBAAA,aAAA,gBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,cAAA,iBAAA,aAAA,iBAPJ,SAOI,cAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBHVR,0BGGI,cAOI,QAAA,iBAPJ,oBAOI,QAAA,uBAPJ,aAOI,QAAA,gBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,aAOI,QAAA,gBAPJ,iBAOI,QAAA,oBAPJ,kBAOI,QAAA,qBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,YAOI,QAAA,eAPJ,eAOI,KAAA,EAAA,EAAA,eAPJ,cAOI,eAAA,cAPJ,iBAOI,eAAA,iBAPJ,sBAOI,eAAA,sBAPJ,yBAOI,eAAA,yBAPJ,iBAOI,UAAA,YAPJ,iBAOI,UAAA,YAPJ,mBAOI,YAAA,YAPJ,mBAOI,YAAA,YAPJ,eAOI,UAAA,eAPJ,iBAOI,UAAA,iBAPJ,uBAOI,UAAA,uBAPJ,2BAOI,gBAAA,qBAPJ,yBAOI,gBAAA,mBAPJ,4BAOI,gBAAA,iBAPJ,6BAOI,gBAAA,wBAPJ,4BAOI,gBAAA,uBAPJ,4BAOI,gBAAA,uBAPJ,uBAOI,YAAA,qBAPJ,qBAOI,YAAA,mBAPJ,wBAOI,YAAA,iBAPJ,0BAOI,YAAA,mBAPJ,yBAOI,YAAA,kBAPJ,yBAOI,cAAA,qBAPJ,uBAOI,cAAA,mBAPJ,0BAOI,cAAA,iBAPJ,2BAOI,cAAA,wBAPJ,0BAOI,cAAA,uBAPJ,2BAOI,cAAA,kBAPJ,qBAOI,WAAA,eAPJ,sBAOI,WAAA,qBAPJ,oBAOI,WAAA,mBAPJ,uBAOI,WAAA,iBAPJ,yBAOI,WAAA,mBAPJ,wBAOI,WAAA,kBAPJ,iBAOI,MAAA,aAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,gBAOI,MAAA,YAPJ,SAOI,OAAA,YAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,gBAPJ,SAOI,OAAA,eAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,eAPJ,YAOI,OAAA,eAPJ,UAOI,aAAA,YAAA,YAAA,YAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,gBAAA,YAAA,gBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,aAAA,iBAAA,YAAA,iBAPJ,UAOI,aAAA,eAAA,YAAA,eAPJ,aAOI,aAAA,eAAA,YAAA,eAPJ,UAOI,WAAA,YAAA,cAAA,YAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,gBAAA,cAAA,gBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,aAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,YAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,gBAPJ,UAOI,WAAA,eAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,eAPJ,aAOI,WAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,aAOI,aAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,aAOI,cAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,aAOI,YAAA,eAPJ,SAOI,QAAA,YAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,gBAPJ,SAOI,QAAA,eAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,eAPJ,UAOI,cAAA,YAAA,aAAA,YAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,gBAAA,aAAA,gBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,cAAA,iBAAA,aAAA,iBAPJ,UAOI,cAAA,eAAA,aAAA,eAPJ,UAOI,YAAA,YAAA,eAAA,YAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,gBAAA,eAAA,gBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,UAOI,eAAA,YAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,gBAPJ,UAOI,eAAA,eAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBCnCZ,aD4BQ,gBAOI,QAAA,iBAPJ,sBAOI,QAAA,uBAPJ,eAOI,QAAA,gBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,eAOI,QAAA,gBAPJ,mBAOI,QAAA,oBAPJ,oBAOI,QAAA,qBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,cAOI,QAAA","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap-grid.css.map */","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css new file mode 100644 index 00000000..1a5d6563 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css @@ -0,0 +1,4084 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + width: 100%; + padding-left: calc(var(--bs-gutter-x) * 0.5); + padding-right: calc(var(--bs-gutter-x) * 0.5); + margin-left: auto; + margin-right: auto; +} + +@media (min-width: 576px) { + .container-sm, .container { + max-width: 540px; + } +} +@media (min-width: 768px) { + .container-md, .container-sm, .container { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container-lg, .container-md, .container-sm, .container { + max-width: 960px; + } +} +@media (min-width: 1200px) { + .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1140px; + } +} +@media (min-width: 1400px) { + .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1320px; + } +} +:root { + --bs-breakpoint-xs: 0; + --bs-breakpoint-sm: 576px; + --bs-breakpoint-md: 768px; + --bs-breakpoint-lg: 992px; + --bs-breakpoint-xl: 1200px; + --bs-breakpoint-xxl: 1400px; +} + +.row { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + display: flex; + flex-wrap: wrap; + margin-top: calc(-1 * var(--bs-gutter-y)); + margin-left: calc(-0.5 * var(--bs-gutter-x)); + margin-right: calc(-0.5 * var(--bs-gutter-x)); +} +.row > * { + box-sizing: border-box; + flex-shrink: 0; + width: 100%; + max-width: 100%; + padding-left: calc(var(--bs-gutter-x) * 0.5); + padding-right: calc(var(--bs-gutter-x) * 0.5); + margin-top: var(--bs-gutter-y); +} + +.col { + flex: 1 0 0%; +} + +.row-cols-auto > * { + flex: 0 0 auto; + width: auto; +} + +.row-cols-1 > * { + flex: 0 0 auto; + width: 100%; +} + +.row-cols-2 > * { + flex: 0 0 auto; + width: 50%; +} + +.row-cols-3 > * { + flex: 0 0 auto; + width: 33.33333333%; +} + +.row-cols-4 > * { + flex: 0 0 auto; + width: 25%; +} + +.row-cols-5 > * { + flex: 0 0 auto; + width: 20%; +} + +.row-cols-6 > * { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-auto { + flex: 0 0 auto; + width: auto; +} + +.col-1 { + flex: 0 0 auto; + width: 8.33333333%; +} + +.col-2 { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-3 { + flex: 0 0 auto; + width: 25%; +} + +.col-4 { + flex: 0 0 auto; + width: 33.33333333%; +} + +.col-5 { + flex: 0 0 auto; + width: 41.66666667%; +} + +.col-6 { + flex: 0 0 auto; + width: 50%; +} + +.col-7 { + flex: 0 0 auto; + width: 58.33333333%; +} + +.col-8 { + flex: 0 0 auto; + width: 66.66666667%; +} + +.col-9 { + flex: 0 0 auto; + width: 75%; +} + +.col-10 { + flex: 0 0 auto; + width: 83.33333333%; +} + +.col-11 { + flex: 0 0 auto; + width: 91.66666667%; +} + +.col-12 { + flex: 0 0 auto; + width: 100%; +} + +.offset-1 { + margin-right: 8.33333333%; +} + +.offset-2 { + margin-right: 16.66666667%; +} + +.offset-3 { + margin-right: 25%; +} + +.offset-4 { + margin-right: 33.33333333%; +} + +.offset-5 { + margin-right: 41.66666667%; +} + +.offset-6 { + margin-right: 50%; +} + +.offset-7 { + margin-right: 58.33333333%; +} + +.offset-8 { + margin-right: 66.66666667%; +} + +.offset-9 { + margin-right: 75%; +} + +.offset-10 { + margin-right: 83.33333333%; +} + +.offset-11 { + margin-right: 91.66666667%; +} + +.g-0, +.gx-0 { + --bs-gutter-x: 0; +} + +.g-0, +.gy-0 { + --bs-gutter-y: 0; +} + +.g-1, +.gx-1 { + --bs-gutter-x: 0.25rem; +} + +.g-1, +.gy-1 { + --bs-gutter-y: 0.25rem; +} + +.g-2, +.gx-2 { + --bs-gutter-x: 0.5rem; +} + +.g-2, +.gy-2 { + --bs-gutter-y: 0.5rem; +} + +.g-3, +.gx-3 { + --bs-gutter-x: 1rem; +} + +.g-3, +.gy-3 { + --bs-gutter-y: 1rem; +} + +.g-4, +.gx-4 { + --bs-gutter-x: 1.5rem; +} + +.g-4, +.gy-4 { + --bs-gutter-y: 1.5rem; +} + +.g-5, +.gx-5 { + --bs-gutter-x: 3rem; +} + +.g-5, +.gy-5 { + --bs-gutter-y: 3rem; +} + +@media (min-width: 576px) { + .col-sm { + flex: 1 0 0%; + } + .row-cols-sm-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-sm-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-sm-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-sm-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-sm-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-sm-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-sm-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-auto { + flex: 0 0 auto; + width: auto; + } + .col-sm-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-sm-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-sm-3 { + flex: 0 0 auto; + width: 25%; + } + .col-sm-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-sm-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-sm-6 { + flex: 0 0 auto; + width: 50%; + } + .col-sm-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-sm-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-sm-9 { + flex: 0 0 auto; + width: 75%; + } + .col-sm-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-sm-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-sm-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-sm-0 { + margin-right: 0; + } + .offset-sm-1 { + margin-right: 8.33333333%; + } + .offset-sm-2 { + margin-right: 16.66666667%; + } + .offset-sm-3 { + margin-right: 25%; + } + .offset-sm-4 { + margin-right: 33.33333333%; + } + .offset-sm-5 { + margin-right: 41.66666667%; + } + .offset-sm-6 { + margin-right: 50%; + } + .offset-sm-7 { + margin-right: 58.33333333%; + } + .offset-sm-8 { + margin-right: 66.66666667%; + } + .offset-sm-9 { + margin-right: 75%; + } + .offset-sm-10 { + margin-right: 83.33333333%; + } + .offset-sm-11 { + margin-right: 91.66666667%; + } + .g-sm-0, + .gx-sm-0 { + --bs-gutter-x: 0; + } + .g-sm-0, + .gy-sm-0 { + --bs-gutter-y: 0; + } + .g-sm-1, + .gx-sm-1 { + --bs-gutter-x: 0.25rem; + } + .g-sm-1, + .gy-sm-1 { + --bs-gutter-y: 0.25rem; + } + .g-sm-2, + .gx-sm-2 { + --bs-gutter-x: 0.5rem; + } + .g-sm-2, + .gy-sm-2 { + --bs-gutter-y: 0.5rem; + } + .g-sm-3, + .gx-sm-3 { + --bs-gutter-x: 1rem; + } + .g-sm-3, + .gy-sm-3 { + --bs-gutter-y: 1rem; + } + .g-sm-4, + .gx-sm-4 { + --bs-gutter-x: 1.5rem; + } + .g-sm-4, + .gy-sm-4 { + --bs-gutter-y: 1.5rem; + } + .g-sm-5, + .gx-sm-5 { + --bs-gutter-x: 3rem; + } + .g-sm-5, + .gy-sm-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 768px) { + .col-md { + flex: 1 0 0%; + } + .row-cols-md-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-md-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-md-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-md-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-md-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-md-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-md-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-auto { + flex: 0 0 auto; + width: auto; + } + .col-md-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-md-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-md-3 { + flex: 0 0 auto; + width: 25%; + } + .col-md-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-md-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-md-6 { + flex: 0 0 auto; + width: 50%; + } + .col-md-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-md-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-md-9 { + flex: 0 0 auto; + width: 75%; + } + .col-md-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-md-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-md-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-md-0 { + margin-right: 0; + } + .offset-md-1 { + margin-right: 8.33333333%; + } + .offset-md-2 { + margin-right: 16.66666667%; + } + .offset-md-3 { + margin-right: 25%; + } + .offset-md-4 { + margin-right: 33.33333333%; + } + .offset-md-5 { + margin-right: 41.66666667%; + } + .offset-md-6 { + margin-right: 50%; + } + .offset-md-7 { + margin-right: 58.33333333%; + } + .offset-md-8 { + margin-right: 66.66666667%; + } + .offset-md-9 { + margin-right: 75%; + } + .offset-md-10 { + margin-right: 83.33333333%; + } + .offset-md-11 { + margin-right: 91.66666667%; + } + .g-md-0, + .gx-md-0 { + --bs-gutter-x: 0; + } + .g-md-0, + .gy-md-0 { + --bs-gutter-y: 0; + } + .g-md-1, + .gx-md-1 { + --bs-gutter-x: 0.25rem; + } + .g-md-1, + .gy-md-1 { + --bs-gutter-y: 0.25rem; + } + .g-md-2, + .gx-md-2 { + --bs-gutter-x: 0.5rem; + } + .g-md-2, + .gy-md-2 { + --bs-gutter-y: 0.5rem; + } + .g-md-3, + .gx-md-3 { + --bs-gutter-x: 1rem; + } + .g-md-3, + .gy-md-3 { + --bs-gutter-y: 1rem; + } + .g-md-4, + .gx-md-4 { + --bs-gutter-x: 1.5rem; + } + .g-md-4, + .gy-md-4 { + --bs-gutter-y: 1.5rem; + } + .g-md-5, + .gx-md-5 { + --bs-gutter-x: 3rem; + } + .g-md-5, + .gy-md-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 992px) { + .col-lg { + flex: 1 0 0%; + } + .row-cols-lg-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-lg-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-lg-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-lg-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-lg-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-lg-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-lg-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-auto { + flex: 0 0 auto; + width: auto; + } + .col-lg-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-lg-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-lg-3 { + flex: 0 0 auto; + width: 25%; + } + .col-lg-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-lg-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-lg-6 { + flex: 0 0 auto; + width: 50%; + } + .col-lg-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-lg-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-lg-9 { + flex: 0 0 auto; + width: 75%; + } + .col-lg-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-lg-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-lg-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-lg-0 { + margin-right: 0; + } + .offset-lg-1 { + margin-right: 8.33333333%; + } + .offset-lg-2 { + margin-right: 16.66666667%; + } + .offset-lg-3 { + margin-right: 25%; + } + .offset-lg-4 { + margin-right: 33.33333333%; + } + .offset-lg-5 { + margin-right: 41.66666667%; + } + .offset-lg-6 { + margin-right: 50%; + } + .offset-lg-7 { + margin-right: 58.33333333%; + } + .offset-lg-8 { + margin-right: 66.66666667%; + } + .offset-lg-9 { + margin-right: 75%; + } + .offset-lg-10 { + margin-right: 83.33333333%; + } + .offset-lg-11 { + margin-right: 91.66666667%; + } + .g-lg-0, + .gx-lg-0 { + --bs-gutter-x: 0; + } + .g-lg-0, + .gy-lg-0 { + --bs-gutter-y: 0; + } + .g-lg-1, + .gx-lg-1 { + --bs-gutter-x: 0.25rem; + } + .g-lg-1, + .gy-lg-1 { + --bs-gutter-y: 0.25rem; + } + .g-lg-2, + .gx-lg-2 { + --bs-gutter-x: 0.5rem; + } + .g-lg-2, + .gy-lg-2 { + --bs-gutter-y: 0.5rem; + } + .g-lg-3, + .gx-lg-3 { + --bs-gutter-x: 1rem; + } + .g-lg-3, + .gy-lg-3 { + --bs-gutter-y: 1rem; + } + .g-lg-4, + .gx-lg-4 { + --bs-gutter-x: 1.5rem; + } + .g-lg-4, + .gy-lg-4 { + --bs-gutter-y: 1.5rem; + } + .g-lg-5, + .gx-lg-5 { + --bs-gutter-x: 3rem; + } + .g-lg-5, + .gy-lg-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1200px) { + .col-xl { + flex: 1 0 0%; + } + .row-cols-xl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xl-0 { + margin-right: 0; + } + .offset-xl-1 { + margin-right: 8.33333333%; + } + .offset-xl-2 { + margin-right: 16.66666667%; + } + .offset-xl-3 { + margin-right: 25%; + } + .offset-xl-4 { + margin-right: 33.33333333%; + } + .offset-xl-5 { + margin-right: 41.66666667%; + } + .offset-xl-6 { + margin-right: 50%; + } + .offset-xl-7 { + margin-right: 58.33333333%; + } + .offset-xl-8 { + margin-right: 66.66666667%; + } + .offset-xl-9 { + margin-right: 75%; + } + .offset-xl-10 { + margin-right: 83.33333333%; + } + .offset-xl-11 { + margin-right: 91.66666667%; + } + .g-xl-0, + .gx-xl-0 { + --bs-gutter-x: 0; + } + .g-xl-0, + .gy-xl-0 { + --bs-gutter-y: 0; + } + .g-xl-1, + .gx-xl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xl-1, + .gy-xl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xl-2, + .gx-xl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xl-2, + .gy-xl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xl-3, + .gx-xl-3 { + --bs-gutter-x: 1rem; + } + .g-xl-3, + .gy-xl-3 { + --bs-gutter-y: 1rem; + } + .g-xl-4, + .gx-xl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xl-4, + .gy-xl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xl-5, + .gx-xl-5 { + --bs-gutter-x: 3rem; + } + .g-xl-5, + .gy-xl-5 { + --bs-gutter-y: 3rem; + } +} +@media (min-width: 1400px) { + .col-xxl { + flex: 1 0 0%; + } + .row-cols-xxl-auto > * { + flex: 0 0 auto; + width: auto; + } + .row-cols-xxl-1 > * { + flex: 0 0 auto; + width: 100%; + } + .row-cols-xxl-2 > * { + flex: 0 0 auto; + width: 50%; + } + .row-cols-xxl-3 > * { + flex: 0 0 auto; + width: 33.33333333%; + } + .row-cols-xxl-4 > * { + flex: 0 0 auto; + width: 25%; + } + .row-cols-xxl-5 > * { + flex: 0 0 auto; + width: 20%; + } + .row-cols-xxl-6 > * { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-auto { + flex: 0 0 auto; + width: auto; + } + .col-xxl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + .col-xxl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + .col-xxl-3 { + flex: 0 0 auto; + width: 25%; + } + .col-xxl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + .col-xxl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + .col-xxl-6 { + flex: 0 0 auto; + width: 50%; + } + .col-xxl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + .col-xxl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + .col-xxl-9 { + flex: 0 0 auto; + width: 75%; + } + .col-xxl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + .col-xxl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + .col-xxl-12 { + flex: 0 0 auto; + width: 100%; + } + .offset-xxl-0 { + margin-right: 0; + } + .offset-xxl-1 { + margin-right: 8.33333333%; + } + .offset-xxl-2 { + margin-right: 16.66666667%; + } + .offset-xxl-3 { + margin-right: 25%; + } + .offset-xxl-4 { + margin-right: 33.33333333%; + } + .offset-xxl-5 { + margin-right: 41.66666667%; + } + .offset-xxl-6 { + margin-right: 50%; + } + .offset-xxl-7 { + margin-right: 58.33333333%; + } + .offset-xxl-8 { + margin-right: 66.66666667%; + } + .offset-xxl-9 { + margin-right: 75%; + } + .offset-xxl-10 { + margin-right: 83.33333333%; + } + .offset-xxl-11 { + margin-right: 91.66666667%; + } + .g-xxl-0, + .gx-xxl-0 { + --bs-gutter-x: 0; + } + .g-xxl-0, + .gy-xxl-0 { + --bs-gutter-y: 0; + } + .g-xxl-1, + .gx-xxl-1 { + --bs-gutter-x: 0.25rem; + } + .g-xxl-1, + .gy-xxl-1 { + --bs-gutter-y: 0.25rem; + } + .g-xxl-2, + .gx-xxl-2 { + --bs-gutter-x: 0.5rem; + } + .g-xxl-2, + .gy-xxl-2 { + --bs-gutter-y: 0.5rem; + } + .g-xxl-3, + .gx-xxl-3 { + --bs-gutter-x: 1rem; + } + .g-xxl-3, + .gy-xxl-3 { + --bs-gutter-y: 1rem; + } + .g-xxl-4, + .gx-xxl-4 { + --bs-gutter-x: 1.5rem; + } + .g-xxl-4, + .gy-xxl-4 { + --bs-gutter-y: 1.5rem; + } + .g-xxl-5, + .gx-xxl-5 { + --bs-gutter-x: 3rem; + } + .g-xxl-5, + .gy-xxl-5 { + --bs-gutter-y: 3rem; + } +} +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-grid { + display: grid !important; +} + +.d-inline-grid { + display: inline-grid !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: flex !important; +} + +.d-inline-flex { + display: inline-flex !important; +} + +.d-none { + display: none !important; +} + +.flex-fill { + flex: 1 1 auto !important; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-column { + flex-direction: column !important; +} + +.flex-row-reverse { + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + flex-direction: column-reverse !important; +} + +.flex-grow-0 { + flex-grow: 0 !important; +} + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + flex-shrink: 1 !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + +.flex-nowrap { + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse !important; +} + +.justify-content-start { + justify-content: flex-start !important; +} + +.justify-content-end { + justify-content: flex-end !important; +} + +.justify-content-center { + justify-content: center !important; +} + +.justify-content-between { + justify-content: space-between !important; +} + +.justify-content-around { + justify-content: space-around !important; +} + +.justify-content-evenly { + justify-content: space-evenly !important; +} + +.align-items-start { + align-items: flex-start !important; +} + +.align-items-end { + align-items: flex-end !important; +} + +.align-items-center { + align-items: center !important; +} + +.align-items-baseline { + align-items: baseline !important; +} + +.align-items-stretch { + align-items: stretch !important; +} + +.align-content-start { + align-content: flex-start !important; +} + +.align-content-end { + align-content: flex-end !important; +} + +.align-content-center { + align-content: center !important; +} + +.align-content-between { + align-content: space-between !important; +} + +.align-content-around { + align-content: space-around !important; +} + +.align-content-stretch { + align-content: stretch !important; +} + +.align-self-auto { + align-self: auto !important; +} + +.align-self-start { + align-self: flex-start !important; +} + +.align-self-end { + align-self: flex-end !important; +} + +.align-self-center { + align-self: center !important; +} + +.align-self-baseline { + align-self: baseline !important; +} + +.align-self-stretch { + align-self: stretch !important; +} + +.order-first { + order: -1 !important; +} + +.order-0 { + order: 0 !important; +} + +.order-1 { + order: 1 !important; +} + +.order-2 { + order: 2 !important; +} + +.order-3 { + order: 3 !important; +} + +.order-4 { + order: 4 !important; +} + +.order-5 { + order: 5 !important; +} + +.order-last { + order: 6 !important; +} + +.m-0 { + margin: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mx-0 { + margin-left: 0 !important; + margin-right: 0 !important; +} + +.mx-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; +} + +.mx-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; +} + +.mx-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; +} + +.mx-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; +} + +.mx-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; +} + +.mx-auto { + margin-left: auto !important; + margin-right: auto !important; +} + +.my-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.my-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; +} + +.my-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.my-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.my-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} + +.my-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} + +.my-auto { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.mt-0 { + margin-top: 0 !important; +} + +.mt-1 { + margin-top: 0.25rem !important; +} + +.mt-2 { + margin-top: 0.5rem !important; +} + +.mt-3 { + margin-top: 1rem !important; +} + +.mt-4 { + margin-top: 1.5rem !important; +} + +.mt-5 { + margin-top: 3rem !important; +} + +.mt-auto { + margin-top: auto !important; +} + +.me-0 { + margin-left: 0 !important; +} + +.me-1 { + margin-left: 0.25rem !important; +} + +.me-2 { + margin-left: 0.5rem !important; +} + +.me-3 { + margin-left: 1rem !important; +} + +.me-4 { + margin-left: 1.5rem !important; +} + +.me-5 { + margin-left: 3rem !important; +} + +.me-auto { + margin-left: auto !important; +} + +.mb-0 { + margin-bottom: 0 !important; +} + +.mb-1 { + margin-bottom: 0.25rem !important; +} + +.mb-2 { + margin-bottom: 0.5rem !important; +} + +.mb-3 { + margin-bottom: 1rem !important; +} + +.mb-4 { + margin-bottom: 1.5rem !important; +} + +.mb-5 { + margin-bottom: 3rem !important; +} + +.mb-auto { + margin-bottom: auto !important; +} + +.ms-0 { + margin-right: 0 !important; +} + +.ms-1 { + margin-right: 0.25rem !important; +} + +.ms-2 { + margin-right: 0.5rem !important; +} + +.ms-3 { + margin-right: 1rem !important; +} + +.ms-4 { + margin-right: 1.5rem !important; +} + +.ms-5 { + margin-right: 3rem !important; +} + +.ms-auto { + margin-right: auto !important; +} + +.p-0 { + padding: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.px-0 { + padding-left: 0 !important; + padding-right: 0 !important; +} + +.px-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; +} + +.px-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; +} + +.px-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; +} + +.px-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; +} + +.px-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; +} + +.py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.py-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; +} + +.py-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; +} + +.py-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; +} + +.py-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} + +.pt-0 { + padding-top: 0 !important; +} + +.pt-1 { + padding-top: 0.25rem !important; +} + +.pt-2 { + padding-top: 0.5rem !important; +} + +.pt-3 { + padding-top: 1rem !important; +} + +.pt-4 { + padding-top: 1.5rem !important; +} + +.pt-5 { + padding-top: 3rem !important; +} + +.pe-0 { + padding-left: 0 !important; +} + +.pe-1 { + padding-left: 0.25rem !important; +} + +.pe-2 { + padding-left: 0.5rem !important; +} + +.pe-3 { + padding-left: 1rem !important; +} + +.pe-4 { + padding-left: 1.5rem !important; +} + +.pe-5 { + padding-left: 3rem !important; +} + +.pb-0 { + padding-bottom: 0 !important; +} + +.pb-1 { + padding-bottom: 0.25rem !important; +} + +.pb-2 { + padding-bottom: 0.5rem !important; +} + +.pb-3 { + padding-bottom: 1rem !important; +} + +.pb-4 { + padding-bottom: 1.5rem !important; +} + +.pb-5 { + padding-bottom: 3rem !important; +} + +.ps-0 { + padding-right: 0 !important; +} + +.ps-1 { + padding-right: 0.25rem !important; +} + +.ps-2 { + padding-right: 0.5rem !important; +} + +.ps-3 { + padding-right: 1rem !important; +} + +.ps-4 { + padding-right: 1.5rem !important; +} + +.ps-5 { + padding-right: 3rem !important; +} + +@media (min-width: 576px) { + .d-sm-inline { + display: inline !important; + } + .d-sm-inline-block { + display: inline-block !important; + } + .d-sm-block { + display: block !important; + } + .d-sm-grid { + display: grid !important; + } + .d-sm-inline-grid { + display: inline-grid !important; + } + .d-sm-table { + display: table !important; + } + .d-sm-table-row { + display: table-row !important; + } + .d-sm-table-cell { + display: table-cell !important; + } + .d-sm-flex { + display: flex !important; + } + .d-sm-inline-flex { + display: inline-flex !important; + } + .d-sm-none { + display: none !important; + } + .flex-sm-fill { + flex: 1 1 auto !important; + } + .flex-sm-row { + flex-direction: row !important; + } + .flex-sm-column { + flex-direction: column !important; + } + .flex-sm-row-reverse { + flex-direction: row-reverse !important; + } + .flex-sm-column-reverse { + flex-direction: column-reverse !important; + } + .flex-sm-grow-0 { + flex-grow: 0 !important; + } + .flex-sm-grow-1 { + flex-grow: 1 !important; + } + .flex-sm-shrink-0 { + flex-shrink: 0 !important; + } + .flex-sm-shrink-1 { + flex-shrink: 1 !important; + } + .flex-sm-wrap { + flex-wrap: wrap !important; + } + .flex-sm-nowrap { + flex-wrap: nowrap !important; + } + .flex-sm-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-sm-start { + justify-content: flex-start !important; + } + .justify-content-sm-end { + justify-content: flex-end !important; + } + .justify-content-sm-center { + justify-content: center !important; + } + .justify-content-sm-between { + justify-content: space-between !important; + } + .justify-content-sm-around { + justify-content: space-around !important; + } + .justify-content-sm-evenly { + justify-content: space-evenly !important; + } + .align-items-sm-start { + align-items: flex-start !important; + } + .align-items-sm-end { + align-items: flex-end !important; + } + .align-items-sm-center { + align-items: center !important; + } + .align-items-sm-baseline { + align-items: baseline !important; + } + .align-items-sm-stretch { + align-items: stretch !important; + } + .align-content-sm-start { + align-content: flex-start !important; + } + .align-content-sm-end { + align-content: flex-end !important; + } + .align-content-sm-center { + align-content: center !important; + } + .align-content-sm-between { + align-content: space-between !important; + } + .align-content-sm-around { + align-content: space-around !important; + } + .align-content-sm-stretch { + align-content: stretch !important; + } + .align-self-sm-auto { + align-self: auto !important; + } + .align-self-sm-start { + align-self: flex-start !important; + } + .align-self-sm-end { + align-self: flex-end !important; + } + .align-self-sm-center { + align-self: center !important; + } + .align-self-sm-baseline { + align-self: baseline !important; + } + .align-self-sm-stretch { + align-self: stretch !important; + } + .order-sm-first { + order: -1 !important; + } + .order-sm-0 { + order: 0 !important; + } + .order-sm-1 { + order: 1 !important; + } + .order-sm-2 { + order: 2 !important; + } + .order-sm-3 { + order: 3 !important; + } + .order-sm-4 { + order: 4 !important; + } + .order-sm-5 { + order: 5 !important; + } + .order-sm-last { + order: 6 !important; + } + .m-sm-0 { + margin: 0 !important; + } + .m-sm-1 { + margin: 0.25rem !important; + } + .m-sm-2 { + margin: 0.5rem !important; + } + .m-sm-3 { + margin: 1rem !important; + } + .m-sm-4 { + margin: 1.5rem !important; + } + .m-sm-5 { + margin: 3rem !important; + } + .m-sm-auto { + margin: auto !important; + } + .mx-sm-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-sm-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-sm-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-sm-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-sm-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-sm-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-sm-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-sm-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-sm-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-sm-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-sm-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-sm-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-sm-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-sm-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-sm-0 { + margin-top: 0 !important; + } + .mt-sm-1 { + margin-top: 0.25rem !important; + } + .mt-sm-2 { + margin-top: 0.5rem !important; + } + .mt-sm-3 { + margin-top: 1rem !important; + } + .mt-sm-4 { + margin-top: 1.5rem !important; + } + .mt-sm-5 { + margin-top: 3rem !important; + } + .mt-sm-auto { + margin-top: auto !important; + } + .me-sm-0 { + margin-left: 0 !important; + } + .me-sm-1 { + margin-left: 0.25rem !important; + } + .me-sm-2 { + margin-left: 0.5rem !important; + } + .me-sm-3 { + margin-left: 1rem !important; + } + .me-sm-4 { + margin-left: 1.5rem !important; + } + .me-sm-5 { + margin-left: 3rem !important; + } + .me-sm-auto { + margin-left: auto !important; + } + .mb-sm-0 { + margin-bottom: 0 !important; + } + .mb-sm-1 { + margin-bottom: 0.25rem !important; + } + .mb-sm-2 { + margin-bottom: 0.5rem !important; + } + .mb-sm-3 { + margin-bottom: 1rem !important; + } + .mb-sm-4 { + margin-bottom: 1.5rem !important; + } + .mb-sm-5 { + margin-bottom: 3rem !important; + } + .mb-sm-auto { + margin-bottom: auto !important; + } + .ms-sm-0 { + margin-right: 0 !important; + } + .ms-sm-1 { + margin-right: 0.25rem !important; + } + .ms-sm-2 { + margin-right: 0.5rem !important; + } + .ms-sm-3 { + margin-right: 1rem !important; + } + .ms-sm-4 { + margin-right: 1.5rem !important; + } + .ms-sm-5 { + margin-right: 3rem !important; + } + .ms-sm-auto { + margin-right: auto !important; + } + .p-sm-0 { + padding: 0 !important; + } + .p-sm-1 { + padding: 0.25rem !important; + } + .p-sm-2 { + padding: 0.5rem !important; + } + .p-sm-3 { + padding: 1rem !important; + } + .p-sm-4 { + padding: 1.5rem !important; + } + .p-sm-5 { + padding: 3rem !important; + } + .px-sm-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-sm-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-sm-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-sm-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-sm-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-sm-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-sm-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-sm-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-sm-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-sm-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-sm-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-sm-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-sm-0 { + padding-top: 0 !important; + } + .pt-sm-1 { + padding-top: 0.25rem !important; + } + .pt-sm-2 { + padding-top: 0.5rem !important; + } + .pt-sm-3 { + padding-top: 1rem !important; + } + .pt-sm-4 { + padding-top: 1.5rem !important; + } + .pt-sm-5 { + padding-top: 3rem !important; + } + .pe-sm-0 { + padding-left: 0 !important; + } + .pe-sm-1 { + padding-left: 0.25rem !important; + } + .pe-sm-2 { + padding-left: 0.5rem !important; + } + .pe-sm-3 { + padding-left: 1rem !important; + } + .pe-sm-4 { + padding-left: 1.5rem !important; + } + .pe-sm-5 { + padding-left: 3rem !important; + } + .pb-sm-0 { + padding-bottom: 0 !important; + } + .pb-sm-1 { + padding-bottom: 0.25rem !important; + } + .pb-sm-2 { + padding-bottom: 0.5rem !important; + } + .pb-sm-3 { + padding-bottom: 1rem !important; + } + .pb-sm-4 { + padding-bottom: 1.5rem !important; + } + .pb-sm-5 { + padding-bottom: 3rem !important; + } + .ps-sm-0 { + padding-right: 0 !important; + } + .ps-sm-1 { + padding-right: 0.25rem !important; + } + .ps-sm-2 { + padding-right: 0.5rem !important; + } + .ps-sm-3 { + padding-right: 1rem !important; + } + .ps-sm-4 { + padding-right: 1.5rem !important; + } + .ps-sm-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 768px) { + .d-md-inline { + display: inline !important; + } + .d-md-inline-block { + display: inline-block !important; + } + .d-md-block { + display: block !important; + } + .d-md-grid { + display: grid !important; + } + .d-md-inline-grid { + display: inline-grid !important; + } + .d-md-table { + display: table !important; + } + .d-md-table-row { + display: table-row !important; + } + .d-md-table-cell { + display: table-cell !important; + } + .d-md-flex { + display: flex !important; + } + .d-md-inline-flex { + display: inline-flex !important; + } + .d-md-none { + display: none !important; + } + .flex-md-fill { + flex: 1 1 auto !important; + } + .flex-md-row { + flex-direction: row !important; + } + .flex-md-column { + flex-direction: column !important; + } + .flex-md-row-reverse { + flex-direction: row-reverse !important; + } + .flex-md-column-reverse { + flex-direction: column-reverse !important; + } + .flex-md-grow-0 { + flex-grow: 0 !important; + } + .flex-md-grow-1 { + flex-grow: 1 !important; + } + .flex-md-shrink-0 { + flex-shrink: 0 !important; + } + .flex-md-shrink-1 { + flex-shrink: 1 !important; + } + .flex-md-wrap { + flex-wrap: wrap !important; + } + .flex-md-nowrap { + flex-wrap: nowrap !important; + } + .flex-md-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-md-start { + justify-content: flex-start !important; + } + .justify-content-md-end { + justify-content: flex-end !important; + } + .justify-content-md-center { + justify-content: center !important; + } + .justify-content-md-between { + justify-content: space-between !important; + } + .justify-content-md-around { + justify-content: space-around !important; + } + .justify-content-md-evenly { + justify-content: space-evenly !important; + } + .align-items-md-start { + align-items: flex-start !important; + } + .align-items-md-end { + align-items: flex-end !important; + } + .align-items-md-center { + align-items: center !important; + } + .align-items-md-baseline { + align-items: baseline !important; + } + .align-items-md-stretch { + align-items: stretch !important; + } + .align-content-md-start { + align-content: flex-start !important; + } + .align-content-md-end { + align-content: flex-end !important; + } + .align-content-md-center { + align-content: center !important; + } + .align-content-md-between { + align-content: space-between !important; + } + .align-content-md-around { + align-content: space-around !important; + } + .align-content-md-stretch { + align-content: stretch !important; + } + .align-self-md-auto { + align-self: auto !important; + } + .align-self-md-start { + align-self: flex-start !important; + } + .align-self-md-end { + align-self: flex-end !important; + } + .align-self-md-center { + align-self: center !important; + } + .align-self-md-baseline { + align-self: baseline !important; + } + .align-self-md-stretch { + align-self: stretch !important; + } + .order-md-first { + order: -1 !important; + } + .order-md-0 { + order: 0 !important; + } + .order-md-1 { + order: 1 !important; + } + .order-md-2 { + order: 2 !important; + } + .order-md-3 { + order: 3 !important; + } + .order-md-4 { + order: 4 !important; + } + .order-md-5 { + order: 5 !important; + } + .order-md-last { + order: 6 !important; + } + .m-md-0 { + margin: 0 !important; + } + .m-md-1 { + margin: 0.25rem !important; + } + .m-md-2 { + margin: 0.5rem !important; + } + .m-md-3 { + margin: 1rem !important; + } + .m-md-4 { + margin: 1.5rem !important; + } + .m-md-5 { + margin: 3rem !important; + } + .m-md-auto { + margin: auto !important; + } + .mx-md-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-md-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-md-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-md-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-md-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-md-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-md-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-md-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-md-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-md-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-md-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-md-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-md-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-md-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-md-0 { + margin-top: 0 !important; + } + .mt-md-1 { + margin-top: 0.25rem !important; + } + .mt-md-2 { + margin-top: 0.5rem !important; + } + .mt-md-3 { + margin-top: 1rem !important; + } + .mt-md-4 { + margin-top: 1.5rem !important; + } + .mt-md-5 { + margin-top: 3rem !important; + } + .mt-md-auto { + margin-top: auto !important; + } + .me-md-0 { + margin-left: 0 !important; + } + .me-md-1 { + margin-left: 0.25rem !important; + } + .me-md-2 { + margin-left: 0.5rem !important; + } + .me-md-3 { + margin-left: 1rem !important; + } + .me-md-4 { + margin-left: 1.5rem !important; + } + .me-md-5 { + margin-left: 3rem !important; + } + .me-md-auto { + margin-left: auto !important; + } + .mb-md-0 { + margin-bottom: 0 !important; + } + .mb-md-1 { + margin-bottom: 0.25rem !important; + } + .mb-md-2 { + margin-bottom: 0.5rem !important; + } + .mb-md-3 { + margin-bottom: 1rem !important; + } + .mb-md-4 { + margin-bottom: 1.5rem !important; + } + .mb-md-5 { + margin-bottom: 3rem !important; + } + .mb-md-auto { + margin-bottom: auto !important; + } + .ms-md-0 { + margin-right: 0 !important; + } + .ms-md-1 { + margin-right: 0.25rem !important; + } + .ms-md-2 { + margin-right: 0.5rem !important; + } + .ms-md-3 { + margin-right: 1rem !important; + } + .ms-md-4 { + margin-right: 1.5rem !important; + } + .ms-md-5 { + margin-right: 3rem !important; + } + .ms-md-auto { + margin-right: auto !important; + } + .p-md-0 { + padding: 0 !important; + } + .p-md-1 { + padding: 0.25rem !important; + } + .p-md-2 { + padding: 0.5rem !important; + } + .p-md-3 { + padding: 1rem !important; + } + .p-md-4 { + padding: 1.5rem !important; + } + .p-md-5 { + padding: 3rem !important; + } + .px-md-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-md-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-md-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-md-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-md-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-md-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-md-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-md-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-md-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-md-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-md-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-md-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-md-0 { + padding-top: 0 !important; + } + .pt-md-1 { + padding-top: 0.25rem !important; + } + .pt-md-2 { + padding-top: 0.5rem !important; + } + .pt-md-3 { + padding-top: 1rem !important; + } + .pt-md-4 { + padding-top: 1.5rem !important; + } + .pt-md-5 { + padding-top: 3rem !important; + } + .pe-md-0 { + padding-left: 0 !important; + } + .pe-md-1 { + padding-left: 0.25rem !important; + } + .pe-md-2 { + padding-left: 0.5rem !important; + } + .pe-md-3 { + padding-left: 1rem !important; + } + .pe-md-4 { + padding-left: 1.5rem !important; + } + .pe-md-5 { + padding-left: 3rem !important; + } + .pb-md-0 { + padding-bottom: 0 !important; + } + .pb-md-1 { + padding-bottom: 0.25rem !important; + } + .pb-md-2 { + padding-bottom: 0.5rem !important; + } + .pb-md-3 { + padding-bottom: 1rem !important; + } + .pb-md-4 { + padding-bottom: 1.5rem !important; + } + .pb-md-5 { + padding-bottom: 3rem !important; + } + .ps-md-0 { + padding-right: 0 !important; + } + .ps-md-1 { + padding-right: 0.25rem !important; + } + .ps-md-2 { + padding-right: 0.5rem !important; + } + .ps-md-3 { + padding-right: 1rem !important; + } + .ps-md-4 { + padding-right: 1.5rem !important; + } + .ps-md-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 992px) { + .d-lg-inline { + display: inline !important; + } + .d-lg-inline-block { + display: inline-block !important; + } + .d-lg-block { + display: block !important; + } + .d-lg-grid { + display: grid !important; + } + .d-lg-inline-grid { + display: inline-grid !important; + } + .d-lg-table { + display: table !important; + } + .d-lg-table-row { + display: table-row !important; + } + .d-lg-table-cell { + display: table-cell !important; + } + .d-lg-flex { + display: flex !important; + } + .d-lg-inline-flex { + display: inline-flex !important; + } + .d-lg-none { + display: none !important; + } + .flex-lg-fill { + flex: 1 1 auto !important; + } + .flex-lg-row { + flex-direction: row !important; + } + .flex-lg-column { + flex-direction: column !important; + } + .flex-lg-row-reverse { + flex-direction: row-reverse !important; + } + .flex-lg-column-reverse { + flex-direction: column-reverse !important; + } + .flex-lg-grow-0 { + flex-grow: 0 !important; + } + .flex-lg-grow-1 { + flex-grow: 1 !important; + } + .flex-lg-shrink-0 { + flex-shrink: 0 !important; + } + .flex-lg-shrink-1 { + flex-shrink: 1 !important; + } + .flex-lg-wrap { + flex-wrap: wrap !important; + } + .flex-lg-nowrap { + flex-wrap: nowrap !important; + } + .flex-lg-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-lg-start { + justify-content: flex-start !important; + } + .justify-content-lg-end { + justify-content: flex-end !important; + } + .justify-content-lg-center { + justify-content: center !important; + } + .justify-content-lg-between { + justify-content: space-between !important; + } + .justify-content-lg-around { + justify-content: space-around !important; + } + .justify-content-lg-evenly { + justify-content: space-evenly !important; + } + .align-items-lg-start { + align-items: flex-start !important; + } + .align-items-lg-end { + align-items: flex-end !important; + } + .align-items-lg-center { + align-items: center !important; + } + .align-items-lg-baseline { + align-items: baseline !important; + } + .align-items-lg-stretch { + align-items: stretch !important; + } + .align-content-lg-start { + align-content: flex-start !important; + } + .align-content-lg-end { + align-content: flex-end !important; + } + .align-content-lg-center { + align-content: center !important; + } + .align-content-lg-between { + align-content: space-between !important; + } + .align-content-lg-around { + align-content: space-around !important; + } + .align-content-lg-stretch { + align-content: stretch !important; + } + .align-self-lg-auto { + align-self: auto !important; + } + .align-self-lg-start { + align-self: flex-start !important; + } + .align-self-lg-end { + align-self: flex-end !important; + } + .align-self-lg-center { + align-self: center !important; + } + .align-self-lg-baseline { + align-self: baseline !important; + } + .align-self-lg-stretch { + align-self: stretch !important; + } + .order-lg-first { + order: -1 !important; + } + .order-lg-0 { + order: 0 !important; + } + .order-lg-1 { + order: 1 !important; + } + .order-lg-2 { + order: 2 !important; + } + .order-lg-3 { + order: 3 !important; + } + .order-lg-4 { + order: 4 !important; + } + .order-lg-5 { + order: 5 !important; + } + .order-lg-last { + order: 6 !important; + } + .m-lg-0 { + margin: 0 !important; + } + .m-lg-1 { + margin: 0.25rem !important; + } + .m-lg-2 { + margin: 0.5rem !important; + } + .m-lg-3 { + margin: 1rem !important; + } + .m-lg-4 { + margin: 1.5rem !important; + } + .m-lg-5 { + margin: 3rem !important; + } + .m-lg-auto { + margin: auto !important; + } + .mx-lg-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-lg-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-lg-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-lg-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-lg-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-lg-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-lg-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-lg-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-lg-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-lg-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-lg-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-lg-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-lg-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-lg-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-lg-0 { + margin-top: 0 !important; + } + .mt-lg-1 { + margin-top: 0.25rem !important; + } + .mt-lg-2 { + margin-top: 0.5rem !important; + } + .mt-lg-3 { + margin-top: 1rem !important; + } + .mt-lg-4 { + margin-top: 1.5rem !important; + } + .mt-lg-5 { + margin-top: 3rem !important; + } + .mt-lg-auto { + margin-top: auto !important; + } + .me-lg-0 { + margin-left: 0 !important; + } + .me-lg-1 { + margin-left: 0.25rem !important; + } + .me-lg-2 { + margin-left: 0.5rem !important; + } + .me-lg-3 { + margin-left: 1rem !important; + } + .me-lg-4 { + margin-left: 1.5rem !important; + } + .me-lg-5 { + margin-left: 3rem !important; + } + .me-lg-auto { + margin-left: auto !important; + } + .mb-lg-0 { + margin-bottom: 0 !important; + } + .mb-lg-1 { + margin-bottom: 0.25rem !important; + } + .mb-lg-2 { + margin-bottom: 0.5rem !important; + } + .mb-lg-3 { + margin-bottom: 1rem !important; + } + .mb-lg-4 { + margin-bottom: 1.5rem !important; + } + .mb-lg-5 { + margin-bottom: 3rem !important; + } + .mb-lg-auto { + margin-bottom: auto !important; + } + .ms-lg-0 { + margin-right: 0 !important; + } + .ms-lg-1 { + margin-right: 0.25rem !important; + } + .ms-lg-2 { + margin-right: 0.5rem !important; + } + .ms-lg-3 { + margin-right: 1rem !important; + } + .ms-lg-4 { + margin-right: 1.5rem !important; + } + .ms-lg-5 { + margin-right: 3rem !important; + } + .ms-lg-auto { + margin-right: auto !important; + } + .p-lg-0 { + padding: 0 !important; + } + .p-lg-1 { + padding: 0.25rem !important; + } + .p-lg-2 { + padding: 0.5rem !important; + } + .p-lg-3 { + padding: 1rem !important; + } + .p-lg-4 { + padding: 1.5rem !important; + } + .p-lg-5 { + padding: 3rem !important; + } + .px-lg-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-lg-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-lg-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-lg-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-lg-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-lg-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-lg-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-lg-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-lg-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-lg-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-lg-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-lg-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-lg-0 { + padding-top: 0 !important; + } + .pt-lg-1 { + padding-top: 0.25rem !important; + } + .pt-lg-2 { + padding-top: 0.5rem !important; + } + .pt-lg-3 { + padding-top: 1rem !important; + } + .pt-lg-4 { + padding-top: 1.5rem !important; + } + .pt-lg-5 { + padding-top: 3rem !important; + } + .pe-lg-0 { + padding-left: 0 !important; + } + .pe-lg-1 { + padding-left: 0.25rem !important; + } + .pe-lg-2 { + padding-left: 0.5rem !important; + } + .pe-lg-3 { + padding-left: 1rem !important; + } + .pe-lg-4 { + padding-left: 1.5rem !important; + } + .pe-lg-5 { + padding-left: 3rem !important; + } + .pb-lg-0 { + padding-bottom: 0 !important; + } + .pb-lg-1 { + padding-bottom: 0.25rem !important; + } + .pb-lg-2 { + padding-bottom: 0.5rem !important; + } + .pb-lg-3 { + padding-bottom: 1rem !important; + } + .pb-lg-4 { + padding-bottom: 1.5rem !important; + } + .pb-lg-5 { + padding-bottom: 3rem !important; + } + .ps-lg-0 { + padding-right: 0 !important; + } + .ps-lg-1 { + padding-right: 0.25rem !important; + } + .ps-lg-2 { + padding-right: 0.5rem !important; + } + .ps-lg-3 { + padding-right: 1rem !important; + } + .ps-lg-4 { + padding-right: 1.5rem !important; + } + .ps-lg-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 1200px) { + .d-xl-inline { + display: inline !important; + } + .d-xl-inline-block { + display: inline-block !important; + } + .d-xl-block { + display: block !important; + } + .d-xl-grid { + display: grid !important; + } + .d-xl-inline-grid { + display: inline-grid !important; + } + .d-xl-table { + display: table !important; + } + .d-xl-table-row { + display: table-row !important; + } + .d-xl-table-cell { + display: table-cell !important; + } + .d-xl-flex { + display: flex !important; + } + .d-xl-inline-flex { + display: inline-flex !important; + } + .d-xl-none { + display: none !important; + } + .flex-xl-fill { + flex: 1 1 auto !important; + } + .flex-xl-row { + flex-direction: row !important; + } + .flex-xl-column { + flex-direction: column !important; + } + .flex-xl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xl-grow-0 { + flex-grow: 0 !important; + } + .flex-xl-grow-1 { + flex-grow: 1 !important; + } + .flex-xl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xl-wrap { + flex-wrap: wrap !important; + } + .flex-xl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xl-start { + justify-content: flex-start !important; + } + .justify-content-xl-end { + justify-content: flex-end !important; + } + .justify-content-xl-center { + justify-content: center !important; + } + .justify-content-xl-between { + justify-content: space-between !important; + } + .justify-content-xl-around { + justify-content: space-around !important; + } + .justify-content-xl-evenly { + justify-content: space-evenly !important; + } + .align-items-xl-start { + align-items: flex-start !important; + } + .align-items-xl-end { + align-items: flex-end !important; + } + .align-items-xl-center { + align-items: center !important; + } + .align-items-xl-baseline { + align-items: baseline !important; + } + .align-items-xl-stretch { + align-items: stretch !important; + } + .align-content-xl-start { + align-content: flex-start !important; + } + .align-content-xl-end { + align-content: flex-end !important; + } + .align-content-xl-center { + align-content: center !important; + } + .align-content-xl-between { + align-content: space-between !important; + } + .align-content-xl-around { + align-content: space-around !important; + } + .align-content-xl-stretch { + align-content: stretch !important; + } + .align-self-xl-auto { + align-self: auto !important; + } + .align-self-xl-start { + align-self: flex-start !important; + } + .align-self-xl-end { + align-self: flex-end !important; + } + .align-self-xl-center { + align-self: center !important; + } + .align-self-xl-baseline { + align-self: baseline !important; + } + .align-self-xl-stretch { + align-self: stretch !important; + } + .order-xl-first { + order: -1 !important; + } + .order-xl-0 { + order: 0 !important; + } + .order-xl-1 { + order: 1 !important; + } + .order-xl-2 { + order: 2 !important; + } + .order-xl-3 { + order: 3 !important; + } + .order-xl-4 { + order: 4 !important; + } + .order-xl-5 { + order: 5 !important; + } + .order-xl-last { + order: 6 !important; + } + .m-xl-0 { + margin: 0 !important; + } + .m-xl-1 { + margin: 0.25rem !important; + } + .m-xl-2 { + margin: 0.5rem !important; + } + .m-xl-3 { + margin: 1rem !important; + } + .m-xl-4 { + margin: 1.5rem !important; + } + .m-xl-5 { + margin: 3rem !important; + } + .m-xl-auto { + margin: auto !important; + } + .mx-xl-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-xl-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-xl-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-xl-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-xl-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-xl-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-xl-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-xl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xl-0 { + margin-top: 0 !important; + } + .mt-xl-1 { + margin-top: 0.25rem !important; + } + .mt-xl-2 { + margin-top: 0.5rem !important; + } + .mt-xl-3 { + margin-top: 1rem !important; + } + .mt-xl-4 { + margin-top: 1.5rem !important; + } + .mt-xl-5 { + margin-top: 3rem !important; + } + .mt-xl-auto { + margin-top: auto !important; + } + .me-xl-0 { + margin-left: 0 !important; + } + .me-xl-1 { + margin-left: 0.25rem !important; + } + .me-xl-2 { + margin-left: 0.5rem !important; + } + .me-xl-3 { + margin-left: 1rem !important; + } + .me-xl-4 { + margin-left: 1.5rem !important; + } + .me-xl-5 { + margin-left: 3rem !important; + } + .me-xl-auto { + margin-left: auto !important; + } + .mb-xl-0 { + margin-bottom: 0 !important; + } + .mb-xl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xl-3 { + margin-bottom: 1rem !important; + } + .mb-xl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xl-5 { + margin-bottom: 3rem !important; + } + .mb-xl-auto { + margin-bottom: auto !important; + } + .ms-xl-0 { + margin-right: 0 !important; + } + .ms-xl-1 { + margin-right: 0.25rem !important; + } + .ms-xl-2 { + margin-right: 0.5rem !important; + } + .ms-xl-3 { + margin-right: 1rem !important; + } + .ms-xl-4 { + margin-right: 1.5rem !important; + } + .ms-xl-5 { + margin-right: 3rem !important; + } + .ms-xl-auto { + margin-right: auto !important; + } + .p-xl-0 { + padding: 0 !important; + } + .p-xl-1 { + padding: 0.25rem !important; + } + .p-xl-2 { + padding: 0.5rem !important; + } + .p-xl-3 { + padding: 1rem !important; + } + .p-xl-4 { + padding: 1.5rem !important; + } + .p-xl-5 { + padding: 3rem !important; + } + .px-xl-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-xl-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-xl-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-xl-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-xl-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-xl-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-xl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xl-0 { + padding-top: 0 !important; + } + .pt-xl-1 { + padding-top: 0.25rem !important; + } + .pt-xl-2 { + padding-top: 0.5rem !important; + } + .pt-xl-3 { + padding-top: 1rem !important; + } + .pt-xl-4 { + padding-top: 1.5rem !important; + } + .pt-xl-5 { + padding-top: 3rem !important; + } + .pe-xl-0 { + padding-left: 0 !important; + } + .pe-xl-1 { + padding-left: 0.25rem !important; + } + .pe-xl-2 { + padding-left: 0.5rem !important; + } + .pe-xl-3 { + padding-left: 1rem !important; + } + .pe-xl-4 { + padding-left: 1.5rem !important; + } + .pe-xl-5 { + padding-left: 3rem !important; + } + .pb-xl-0 { + padding-bottom: 0 !important; + } + .pb-xl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xl-3 { + padding-bottom: 1rem !important; + } + .pb-xl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xl-5 { + padding-bottom: 3rem !important; + } + .ps-xl-0 { + padding-right: 0 !important; + } + .ps-xl-1 { + padding-right: 0.25rem !important; + } + .ps-xl-2 { + padding-right: 0.5rem !important; + } + .ps-xl-3 { + padding-right: 1rem !important; + } + .ps-xl-4 { + padding-right: 1.5rem !important; + } + .ps-xl-5 { + padding-right: 3rem !important; + } +} +@media (min-width: 1400px) { + .d-xxl-inline { + display: inline !important; + } + .d-xxl-inline-block { + display: inline-block !important; + } + .d-xxl-block { + display: block !important; + } + .d-xxl-grid { + display: grid !important; + } + .d-xxl-inline-grid { + display: inline-grid !important; + } + .d-xxl-table { + display: table !important; + } + .d-xxl-table-row { + display: table-row !important; + } + .d-xxl-table-cell { + display: table-cell !important; + } + .d-xxl-flex { + display: flex !important; + } + .d-xxl-inline-flex { + display: inline-flex !important; + } + .d-xxl-none { + display: none !important; + } + .flex-xxl-fill { + flex: 1 1 auto !important; + } + .flex-xxl-row { + flex-direction: row !important; + } + .flex-xxl-column { + flex-direction: column !important; + } + .flex-xxl-row-reverse { + flex-direction: row-reverse !important; + } + .flex-xxl-column-reverse { + flex-direction: column-reverse !important; + } + .flex-xxl-grow-0 { + flex-grow: 0 !important; + } + .flex-xxl-grow-1 { + flex-grow: 1 !important; + } + .flex-xxl-shrink-0 { + flex-shrink: 0 !important; + } + .flex-xxl-shrink-1 { + flex-shrink: 1 !important; + } + .flex-xxl-wrap { + flex-wrap: wrap !important; + } + .flex-xxl-nowrap { + flex-wrap: nowrap !important; + } + .flex-xxl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + .justify-content-xxl-start { + justify-content: flex-start !important; + } + .justify-content-xxl-end { + justify-content: flex-end !important; + } + .justify-content-xxl-center { + justify-content: center !important; + } + .justify-content-xxl-between { + justify-content: space-between !important; + } + .justify-content-xxl-around { + justify-content: space-around !important; + } + .justify-content-xxl-evenly { + justify-content: space-evenly !important; + } + .align-items-xxl-start { + align-items: flex-start !important; + } + .align-items-xxl-end { + align-items: flex-end !important; + } + .align-items-xxl-center { + align-items: center !important; + } + .align-items-xxl-baseline { + align-items: baseline !important; + } + .align-items-xxl-stretch { + align-items: stretch !important; + } + .align-content-xxl-start { + align-content: flex-start !important; + } + .align-content-xxl-end { + align-content: flex-end !important; + } + .align-content-xxl-center { + align-content: center !important; + } + .align-content-xxl-between { + align-content: space-between !important; + } + .align-content-xxl-around { + align-content: space-around !important; + } + .align-content-xxl-stretch { + align-content: stretch !important; + } + .align-self-xxl-auto { + align-self: auto !important; + } + .align-self-xxl-start { + align-self: flex-start !important; + } + .align-self-xxl-end { + align-self: flex-end !important; + } + .align-self-xxl-center { + align-self: center !important; + } + .align-self-xxl-baseline { + align-self: baseline !important; + } + .align-self-xxl-stretch { + align-self: stretch !important; + } + .order-xxl-first { + order: -1 !important; + } + .order-xxl-0 { + order: 0 !important; + } + .order-xxl-1 { + order: 1 !important; + } + .order-xxl-2 { + order: 2 !important; + } + .order-xxl-3 { + order: 3 !important; + } + .order-xxl-4 { + order: 4 !important; + } + .order-xxl-5 { + order: 5 !important; + } + .order-xxl-last { + order: 6 !important; + } + .m-xxl-0 { + margin: 0 !important; + } + .m-xxl-1 { + margin: 0.25rem !important; + } + .m-xxl-2 { + margin: 0.5rem !important; + } + .m-xxl-3 { + margin: 1rem !important; + } + .m-xxl-4 { + margin: 1.5rem !important; + } + .m-xxl-5 { + margin: 3rem !important; + } + .m-xxl-auto { + margin: auto !important; + } + .mx-xxl-0 { + margin-left: 0 !important; + margin-right: 0 !important; + } + .mx-xxl-1 { + margin-left: 0.25rem !important; + margin-right: 0.25rem !important; + } + .mx-xxl-2 { + margin-left: 0.5rem !important; + margin-right: 0.5rem !important; + } + .mx-xxl-3 { + margin-left: 1rem !important; + margin-right: 1rem !important; + } + .mx-xxl-4 { + margin-left: 1.5rem !important; + margin-right: 1.5rem !important; + } + .mx-xxl-5 { + margin-left: 3rem !important; + margin-right: 3rem !important; + } + .mx-xxl-auto { + margin-left: auto !important; + margin-right: auto !important; + } + .my-xxl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + .my-xxl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xxl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + .my-xxl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + .my-xxl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + .my-xxl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + .my-xxl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + .mt-xxl-0 { + margin-top: 0 !important; + } + .mt-xxl-1 { + margin-top: 0.25rem !important; + } + .mt-xxl-2 { + margin-top: 0.5rem !important; + } + .mt-xxl-3 { + margin-top: 1rem !important; + } + .mt-xxl-4 { + margin-top: 1.5rem !important; + } + .mt-xxl-5 { + margin-top: 3rem !important; + } + .mt-xxl-auto { + margin-top: auto !important; + } + .me-xxl-0 { + margin-left: 0 !important; + } + .me-xxl-1 { + margin-left: 0.25rem !important; + } + .me-xxl-2 { + margin-left: 0.5rem !important; + } + .me-xxl-3 { + margin-left: 1rem !important; + } + .me-xxl-4 { + margin-left: 1.5rem !important; + } + .me-xxl-5 { + margin-left: 3rem !important; + } + .me-xxl-auto { + margin-left: auto !important; + } + .mb-xxl-0 { + margin-bottom: 0 !important; + } + .mb-xxl-1 { + margin-bottom: 0.25rem !important; + } + .mb-xxl-2 { + margin-bottom: 0.5rem !important; + } + .mb-xxl-3 { + margin-bottom: 1rem !important; + } + .mb-xxl-4 { + margin-bottom: 1.5rem !important; + } + .mb-xxl-5 { + margin-bottom: 3rem !important; + } + .mb-xxl-auto { + margin-bottom: auto !important; + } + .ms-xxl-0 { + margin-right: 0 !important; + } + .ms-xxl-1 { + margin-right: 0.25rem !important; + } + .ms-xxl-2 { + margin-right: 0.5rem !important; + } + .ms-xxl-3 { + margin-right: 1rem !important; + } + .ms-xxl-4 { + margin-right: 1.5rem !important; + } + .ms-xxl-5 { + margin-right: 3rem !important; + } + .ms-xxl-auto { + margin-right: auto !important; + } + .p-xxl-0 { + padding: 0 !important; + } + .p-xxl-1 { + padding: 0.25rem !important; + } + .p-xxl-2 { + padding: 0.5rem !important; + } + .p-xxl-3 { + padding: 1rem !important; + } + .p-xxl-4 { + padding: 1.5rem !important; + } + .p-xxl-5 { + padding: 3rem !important; + } + .px-xxl-0 { + padding-left: 0 !important; + padding-right: 0 !important; + } + .px-xxl-1 { + padding-left: 0.25rem !important; + padding-right: 0.25rem !important; + } + .px-xxl-2 { + padding-left: 0.5rem !important; + padding-right: 0.5rem !important; + } + .px-xxl-3 { + padding-left: 1rem !important; + padding-right: 1rem !important; + } + .px-xxl-4 { + padding-left: 1.5rem !important; + padding-right: 1.5rem !important; + } + .px-xxl-5 { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + .py-xxl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + .py-xxl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xxl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + .py-xxl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + .py-xxl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + .py-xxl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + .pt-xxl-0 { + padding-top: 0 !important; + } + .pt-xxl-1 { + padding-top: 0.25rem !important; + } + .pt-xxl-2 { + padding-top: 0.5rem !important; + } + .pt-xxl-3 { + padding-top: 1rem !important; + } + .pt-xxl-4 { + padding-top: 1.5rem !important; + } + .pt-xxl-5 { + padding-top: 3rem !important; + } + .pe-xxl-0 { + padding-left: 0 !important; + } + .pe-xxl-1 { + padding-left: 0.25rem !important; + } + .pe-xxl-2 { + padding-left: 0.5rem !important; + } + .pe-xxl-3 { + padding-left: 1rem !important; + } + .pe-xxl-4 { + padding-left: 1.5rem !important; + } + .pe-xxl-5 { + padding-left: 3rem !important; + } + .pb-xxl-0 { + padding-bottom: 0 !important; + } + .pb-xxl-1 { + padding-bottom: 0.25rem !important; + } + .pb-xxl-2 { + padding-bottom: 0.5rem !important; + } + .pb-xxl-3 { + padding-bottom: 1rem !important; + } + .pb-xxl-4 { + padding-bottom: 1.5rem !important; + } + .pb-xxl-5 { + padding-bottom: 3rem !important; + } + .ps-xxl-0 { + padding-right: 0 !important; + } + .ps-xxl-1 { + padding-right: 0.25rem !important; + } + .ps-xxl-2 { + padding-right: 0.5rem !important; + } + .ps-xxl-3 { + padding-right: 1rem !important; + } + .ps-xxl-4 { + padding-right: 1.5rem !important; + } + .ps-xxl-5 { + padding-right: 3rem !important; + } +} +@media print { + .d-print-inline { + display: inline !important; + } + .d-print-inline-block { + display: inline-block !important; + } + .d-print-block { + display: block !important; + } + .d-print-grid { + display: grid !important; + } + .d-print-inline-grid { + display: inline-grid !important; + } + .d-print-table { + display: table !important; + } + .d-print-table-row { + display: table-row !important; + } + .d-print-table-cell { + display: table-cell !important; + } + .d-print-flex { + display: flex !important; + } + .d-print-inline-flex { + display: inline-flex !important; + } + .d-print-none { + display: none !important; + } +} +/*# sourceMappingURL=bootstrap-grid.rtl.css.map */ \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map new file mode 100644 index 00000000..8df43cfc --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","bootstrap-grid.css","../../scss/mixins/_breakpoints.scss","../../scss/_variables.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"AACE;;;;EAAA;ACKA;;;;;;;ECHA,qBAAA;EACA,gBAAA;EACA,WAAA;EACA,4CAAA;EACA,6CAAA;EACA,iBAAA;EACA,kBAAA;ACUF;;AC4CI;EH5CE;IACE,gBIkee;EF9drB;AACF;ACsCI;EH5CE;IACE,gBIkee;EFzdrB;AACF;ACiCI;EH5CE;IACE,gBIkee;EFpdrB;AACF;AC4BI;EH5CE;IACE,iBIkee;EF/crB;AACF;ACuBI;EH5CE;IACE,iBIkee;EF1crB;AACF;AGzCA;EAEI,qBAAA;EAAA,yBAAA;EAAA,yBAAA;EAAA,yBAAA;EAAA,0BAAA;EAAA,2BAAA;AH+CJ;;AG1CE;ECNA,qBAAA;EACA,gBAAA;EACA,aAAA;EACA,eAAA;EAEA,yCAAA;EACA,4CAAA;EACA,6CAAA;AJmDF;AGjDI;ECGF,sBAAA;EAIA,cAAA;EACA,WAAA;EACA,eAAA;EACA,4CAAA;EACA,6CAAA;EACA,8BAAA;AJ8CF;;AICM;EACE,YAAA;AJER;;AICM;EApCJ,cAAA;EACA,WAAA;AJuCF;;AIzBE;EACE,cAAA;EACA,WAAA;AJ4BJ;;AI9BE;EACE,cAAA;EACA,UAAA;AJiCJ;;AInCE;EACE,cAAA;EACA,mBAAA;AJsCJ;;AIxCE;EACE,cAAA;EACA,UAAA;AJ2CJ;;AI7CE;EACE,cAAA;EACA,UAAA;AJgDJ;;AIlDE;EACE,cAAA;EACA,mBAAA;AJqDJ;;AItBM;EAhDJ,cAAA;EACA,WAAA;AJ0EF;;AIrBU;EAhEN,cAAA;EACA,kBAAA;AJyFJ;;AI1BU;EAhEN,cAAA;EACA,mBAAA;AJ8FJ;;AI/BU;EAhEN,cAAA;EACA,UAAA;AJmGJ;;AIpCU;EAhEN,cAAA;EACA,mBAAA;AJwGJ;;AIzCU;EAhEN,cAAA;EACA,mBAAA;AJ6GJ;;AI9CU;EAhEN,cAAA;EACA,UAAA;AJkHJ;;AInDU;EAhEN,cAAA;EACA,mBAAA;AJuHJ;;AIxDU;EAhEN,cAAA;EACA,mBAAA;AJ4HJ;;AI7DU;EAhEN,cAAA;EACA,UAAA;AJiIJ;;AIlEU;EAhEN,cAAA;EACA,mBAAA;AJsIJ;;AIvEU;EAhEN,cAAA;EACA,mBAAA;AJ2IJ;;AI5EU;EAhEN,cAAA;EACA,WAAA;AJgJJ;;AIzEY;EAxDV,yBAAA;AJqIF;;AI7EY;EAxDV,0BAAA;AJyIF;;AIjFY;EAxDV,iBAAA;AJ6IF;;AIrFY;EAxDV,0BAAA;AJiJF;;AIzFY;EAxDV,0BAAA;AJqJF;;AI7FY;EAxDV,iBAAA;AJyJF;;AIjGY;EAxDV,0BAAA;AJ6JF;;AIrGY;EAxDV,0BAAA;AJiKF;;AIzGY;EAxDV,iBAAA;AJqKF;;AI7GY;EAxDV,0BAAA;AJyKF;;AIjHY;EAxDV,0BAAA;AJ6KF;;AI1GQ;;EAEE,gBAAA;AJ6GV;;AI1GQ;;EAEE,gBAAA;AJ6GV;;AIpHQ;;EAEE,sBAAA;AJuHV;;AIpHQ;;EAEE,sBAAA;AJuHV;;AI9HQ;;EAEE,qBAAA;AJiIV;;AI9HQ;;EAEE,qBAAA;AJiIV;;AIxIQ;;EAEE,mBAAA;AJ2IV;;AIxIQ;;EAEE,mBAAA;AJ2IV;;AIlJQ;;EAEE,qBAAA;AJqJV;;AIlJQ;;EAEE,qBAAA;AJqJV;;AI5JQ;;EAEE,mBAAA;AJ+JV;;AI5JQ;;EAEE,mBAAA;AJ+JV;;ACzNI;EGUE;IACE,YAAA;EJmNN;EIhNI;IApCJ,cAAA;IACA,WAAA;EJuPA;EIzOA;IACE,cAAA;IACA,WAAA;EJ2OF;EI7OA;IACE,cAAA;IACA,UAAA;EJ+OF;EIjPA;IACE,cAAA;IACA,mBAAA;EJmPF;EIrPA;IACE,cAAA;IACA,UAAA;EJuPF;EIzPA;IACE,cAAA;IACA,UAAA;EJ2PF;EI7PA;IACE,cAAA;IACA,mBAAA;EJ+PF;EIhOI;IAhDJ,cAAA;IACA,WAAA;EJmRA;EI9NQ;IAhEN,cAAA;IACA,kBAAA;EJiSF;EIlOQ;IAhEN,cAAA;IACA,mBAAA;EJqSF;EItOQ;IAhEN,cAAA;IACA,UAAA;EJySF;EI1OQ;IAhEN,cAAA;IACA,mBAAA;EJ6SF;EI9OQ;IAhEN,cAAA;IACA,mBAAA;EJiTF;EIlPQ;IAhEN,cAAA;IACA,UAAA;EJqTF;EItPQ;IAhEN,cAAA;IACA,mBAAA;EJyTF;EI1PQ;IAhEN,cAAA;IACA,mBAAA;EJ6TF;EI9PQ;IAhEN,cAAA;IACA,UAAA;EJiUF;EIlQQ;IAhEN,cAAA;IACA,mBAAA;EJqUF;EItQQ;IAhEN,cAAA;IACA,mBAAA;EJyUF;EI1QQ;IAhEN,cAAA;IACA,WAAA;EJ6UF;EItQU;IAxDV,eAAA;EJiUA;EIzQU;IAxDV,yBAAA;EJoUA;EI5QU;IAxDV,0BAAA;EJuUA;EI/QU;IAxDV,iBAAA;EJ0UA;EIlRU;IAxDV,0BAAA;EJ6UA;EIrRU;IAxDV,0BAAA;EJgVA;EIxRU;IAxDV,iBAAA;EJmVA;EI3RU;IAxDV,0BAAA;EJsVA;EI9RU;IAxDV,0BAAA;EJyVA;EIjSU;IAxDV,iBAAA;EJ4VA;EIpSU;IAxDV,0BAAA;EJ+VA;EIvSU;IAxDV,0BAAA;EJkWA;EI/RM;;IAEE,gBAAA;EJiSR;EI9RM;;IAEE,gBAAA;EJgSR;EIvSM;;IAEE,sBAAA;EJySR;EItSM;;IAEE,sBAAA;EJwSR;EI/SM;;IAEE,qBAAA;EJiTR;EI9SM;;IAEE,qBAAA;EJgTR;EIvTM;;IAEE,mBAAA;EJyTR;EItTM;;IAEE,mBAAA;EJwTR;EI/TM;;IAEE,qBAAA;EJiUR;EI9TM;;IAEE,qBAAA;EJgUR;EIvUM;;IAEE,mBAAA;EJyUR;EItUM;;IAEE,mBAAA;EJwUR;AACF;ACnYI;EGUE;IACE,YAAA;EJ4XN;EIzXI;IApCJ,cAAA;IACA,WAAA;EJgaA;EIlZA;IACE,cAAA;IACA,WAAA;EJoZF;EItZA;IACE,cAAA;IACA,UAAA;EJwZF;EI1ZA;IACE,cAAA;IACA,mBAAA;EJ4ZF;EI9ZA;IACE,cAAA;IACA,UAAA;EJgaF;EIlaA;IACE,cAAA;IACA,UAAA;EJoaF;EItaA;IACE,cAAA;IACA,mBAAA;EJwaF;EIzYI;IAhDJ,cAAA;IACA,WAAA;EJ4bA;EIvYQ;IAhEN,cAAA;IACA,kBAAA;EJ0cF;EI3YQ;IAhEN,cAAA;IACA,mBAAA;EJ8cF;EI/YQ;IAhEN,cAAA;IACA,UAAA;EJkdF;EInZQ;IAhEN,cAAA;IACA,mBAAA;EJsdF;EIvZQ;IAhEN,cAAA;IACA,mBAAA;EJ0dF;EI3ZQ;IAhEN,cAAA;IACA,UAAA;EJ8dF;EI/ZQ;IAhEN,cAAA;IACA,mBAAA;EJkeF;EInaQ;IAhEN,cAAA;IACA,mBAAA;EJseF;EIvaQ;IAhEN,cAAA;IACA,UAAA;EJ0eF;EI3aQ;IAhEN,cAAA;IACA,mBAAA;EJ8eF;EI/aQ;IAhEN,cAAA;IACA,mBAAA;EJkfF;EInbQ;IAhEN,cAAA;IACA,WAAA;EJsfF;EI/aU;IAxDV,eAAA;EJ0eA;EIlbU;IAxDV,yBAAA;EJ6eA;EIrbU;IAxDV,0BAAA;EJgfA;EIxbU;IAxDV,iBAAA;EJmfA;EI3bU;IAxDV,0BAAA;EJsfA;EI9bU;IAxDV,0BAAA;EJyfA;EIjcU;IAxDV,iBAAA;EJ4fA;EIpcU;IAxDV,0BAAA;EJ+fA;EIvcU;IAxDV,0BAAA;EJkgBA;EI1cU;IAxDV,iBAAA;EJqgBA;EI7cU;IAxDV,0BAAA;EJwgBA;EIhdU;IAxDV,0BAAA;EJ2gBA;EIxcM;;IAEE,gBAAA;EJ0cR;EIvcM;;IAEE,gBAAA;EJycR;EIhdM;;IAEE,sBAAA;EJkdR;EI/cM;;IAEE,sBAAA;EJidR;EIxdM;;IAEE,qBAAA;EJ0dR;EIvdM;;IAEE,qBAAA;EJydR;EIheM;;IAEE,mBAAA;EJkeR;EI/dM;;IAEE,mBAAA;EJieR;EIxeM;;IAEE,qBAAA;EJ0eR;EIveM;;IAEE,qBAAA;EJyeR;EIhfM;;IAEE,mBAAA;EJkfR;EI/eM;;IAEE,mBAAA;EJifR;AACF;AC5iBI;EGUE;IACE,YAAA;EJqiBN;EIliBI;IApCJ,cAAA;IACA,WAAA;EJykBA;EI3jBA;IACE,cAAA;IACA,WAAA;EJ6jBF;EI/jBA;IACE,cAAA;IACA,UAAA;EJikBF;EInkBA;IACE,cAAA;IACA,mBAAA;EJqkBF;EIvkBA;IACE,cAAA;IACA,UAAA;EJykBF;EI3kBA;IACE,cAAA;IACA,UAAA;EJ6kBF;EI/kBA;IACE,cAAA;IACA,mBAAA;EJilBF;EIljBI;IAhDJ,cAAA;IACA,WAAA;EJqmBA;EIhjBQ;IAhEN,cAAA;IACA,kBAAA;EJmnBF;EIpjBQ;IAhEN,cAAA;IACA,mBAAA;EJunBF;EIxjBQ;IAhEN,cAAA;IACA,UAAA;EJ2nBF;EI5jBQ;IAhEN,cAAA;IACA,mBAAA;EJ+nBF;EIhkBQ;IAhEN,cAAA;IACA,mBAAA;EJmoBF;EIpkBQ;IAhEN,cAAA;IACA,UAAA;EJuoBF;EIxkBQ;IAhEN,cAAA;IACA,mBAAA;EJ2oBF;EI5kBQ;IAhEN,cAAA;IACA,mBAAA;EJ+oBF;EIhlBQ;IAhEN,cAAA;IACA,UAAA;EJmpBF;EIplBQ;IAhEN,cAAA;IACA,mBAAA;EJupBF;EIxlBQ;IAhEN,cAAA;IACA,mBAAA;EJ2pBF;EI5lBQ;IAhEN,cAAA;IACA,WAAA;EJ+pBF;EIxlBU;IAxDV,eAAA;EJmpBA;EI3lBU;IAxDV,yBAAA;EJspBA;EI9lBU;IAxDV,0BAAA;EJypBA;EIjmBU;IAxDV,iBAAA;EJ4pBA;EIpmBU;IAxDV,0BAAA;EJ+pBA;EIvmBU;IAxDV,0BAAA;EJkqBA;EI1mBU;IAxDV,iBAAA;EJqqBA;EI7mBU;IAxDV,0BAAA;EJwqBA;EIhnBU;IAxDV,0BAAA;EJ2qBA;EInnBU;IAxDV,iBAAA;EJ8qBA;EItnBU;IAxDV,0BAAA;EJirBA;EIznBU;IAxDV,0BAAA;EJorBA;EIjnBM;;IAEE,gBAAA;EJmnBR;EIhnBM;;IAEE,gBAAA;EJknBR;EIznBM;;IAEE,sBAAA;EJ2nBR;EIxnBM;;IAEE,sBAAA;EJ0nBR;EIjoBM;;IAEE,qBAAA;EJmoBR;EIhoBM;;IAEE,qBAAA;EJkoBR;EIzoBM;;IAEE,mBAAA;EJ2oBR;EIxoBM;;IAEE,mBAAA;EJ0oBR;EIjpBM;;IAEE,qBAAA;EJmpBR;EIhpBM;;IAEE,qBAAA;EJkpBR;EIzpBM;;IAEE,mBAAA;EJ2pBR;EIxpBM;;IAEE,mBAAA;EJ0pBR;AACF;ACrtBI;EGUE;IACE,YAAA;EJ8sBN;EI3sBI;IApCJ,cAAA;IACA,WAAA;EJkvBA;EIpuBA;IACE,cAAA;IACA,WAAA;EJsuBF;EIxuBA;IACE,cAAA;IACA,UAAA;EJ0uBF;EI5uBA;IACE,cAAA;IACA,mBAAA;EJ8uBF;EIhvBA;IACE,cAAA;IACA,UAAA;EJkvBF;EIpvBA;IACE,cAAA;IACA,UAAA;EJsvBF;EIxvBA;IACE,cAAA;IACA,mBAAA;EJ0vBF;EI3tBI;IAhDJ,cAAA;IACA,WAAA;EJ8wBA;EIztBQ;IAhEN,cAAA;IACA,kBAAA;EJ4xBF;EI7tBQ;IAhEN,cAAA;IACA,mBAAA;EJgyBF;EIjuBQ;IAhEN,cAAA;IACA,UAAA;EJoyBF;EIruBQ;IAhEN,cAAA;IACA,mBAAA;EJwyBF;EIzuBQ;IAhEN,cAAA;IACA,mBAAA;EJ4yBF;EI7uBQ;IAhEN,cAAA;IACA,UAAA;EJgzBF;EIjvBQ;IAhEN,cAAA;IACA,mBAAA;EJozBF;EIrvBQ;IAhEN,cAAA;IACA,mBAAA;EJwzBF;EIzvBQ;IAhEN,cAAA;IACA,UAAA;EJ4zBF;EI7vBQ;IAhEN,cAAA;IACA,mBAAA;EJg0BF;EIjwBQ;IAhEN,cAAA;IACA,mBAAA;EJo0BF;EIrwBQ;IAhEN,cAAA;IACA,WAAA;EJw0BF;EIjwBU;IAxDV,eAAA;EJ4zBA;EIpwBU;IAxDV,yBAAA;EJ+zBA;EIvwBU;IAxDV,0BAAA;EJk0BA;EI1wBU;IAxDV,iBAAA;EJq0BA;EI7wBU;IAxDV,0BAAA;EJw0BA;EIhxBU;IAxDV,0BAAA;EJ20BA;EInxBU;IAxDV,iBAAA;EJ80BA;EItxBU;IAxDV,0BAAA;EJi1BA;EIzxBU;IAxDV,0BAAA;EJo1BA;EI5xBU;IAxDV,iBAAA;EJu1BA;EI/xBU;IAxDV,0BAAA;EJ01BA;EIlyBU;IAxDV,0BAAA;EJ61BA;EI1xBM;;IAEE,gBAAA;EJ4xBR;EIzxBM;;IAEE,gBAAA;EJ2xBR;EIlyBM;;IAEE,sBAAA;EJoyBR;EIjyBM;;IAEE,sBAAA;EJmyBR;EI1yBM;;IAEE,qBAAA;EJ4yBR;EIzyBM;;IAEE,qBAAA;EJ2yBR;EIlzBM;;IAEE,mBAAA;EJozBR;EIjzBM;;IAEE,mBAAA;EJmzBR;EI1zBM;;IAEE,qBAAA;EJ4zBR;EIzzBM;;IAEE,qBAAA;EJ2zBR;EIl0BM;;IAEE,mBAAA;EJo0BR;EIj0BM;;IAEE,mBAAA;EJm0BR;AACF;AC93BI;EGUE;IACE,YAAA;EJu3BN;EIp3BI;IApCJ,cAAA;IACA,WAAA;EJ25BA;EI74BA;IACE,cAAA;IACA,WAAA;EJ+4BF;EIj5BA;IACE,cAAA;IACA,UAAA;EJm5BF;EIr5BA;IACE,cAAA;IACA,mBAAA;EJu5BF;EIz5BA;IACE,cAAA;IACA,UAAA;EJ25BF;EI75BA;IACE,cAAA;IACA,UAAA;EJ+5BF;EIj6BA;IACE,cAAA;IACA,mBAAA;EJm6BF;EIp4BI;IAhDJ,cAAA;IACA,WAAA;EJu7BA;EIl4BQ;IAhEN,cAAA;IACA,kBAAA;EJq8BF;EIt4BQ;IAhEN,cAAA;IACA,mBAAA;EJy8BF;EI14BQ;IAhEN,cAAA;IACA,UAAA;EJ68BF;EI94BQ;IAhEN,cAAA;IACA,mBAAA;EJi9BF;EIl5BQ;IAhEN,cAAA;IACA,mBAAA;EJq9BF;EIt5BQ;IAhEN,cAAA;IACA,UAAA;EJy9BF;EI15BQ;IAhEN,cAAA;IACA,mBAAA;EJ69BF;EI95BQ;IAhEN,cAAA;IACA,mBAAA;EJi+BF;EIl6BQ;IAhEN,cAAA;IACA,UAAA;EJq+BF;EIt6BQ;IAhEN,cAAA;IACA,mBAAA;EJy+BF;EI16BQ;IAhEN,cAAA;IACA,mBAAA;EJ6+BF;EI96BQ;IAhEN,cAAA;IACA,WAAA;EJi/BF;EI16BU;IAxDV,eAAA;EJq+BA;EI76BU;IAxDV,yBAAA;EJw+BA;EIh7BU;IAxDV,0BAAA;EJ2+BA;EIn7BU;IAxDV,iBAAA;EJ8+BA;EIt7BU;IAxDV,0BAAA;EJi/BA;EIz7BU;IAxDV,0BAAA;EJo/BA;EI57BU;IAxDV,iBAAA;EJu/BA;EI/7BU;IAxDV,0BAAA;EJ0/BA;EIl8BU;IAxDV,0BAAA;EJ6/BA;EIr8BU;IAxDV,iBAAA;EJggCA;EIx8BU;IAxDV,0BAAA;EJmgCA;EI38BU;IAxDV,0BAAA;EJsgCA;EIn8BM;;IAEE,gBAAA;EJq8BR;EIl8BM;;IAEE,gBAAA;EJo8BR;EI38BM;;IAEE,sBAAA;EJ68BR;EI18BM;;IAEE,sBAAA;EJ48BR;EIn9BM;;IAEE,qBAAA;EJq9BR;EIl9BM;;IAEE,qBAAA;EJo9BR;EI39BM;;IAEE,mBAAA;EJ69BR;EI19BM;;IAEE,mBAAA;EJ49BR;EIn+BM;;IAEE,qBAAA;EJq+BR;EIl+BM;;IAEE,qBAAA;EJo+BR;EI3+BM;;IAEE,mBAAA;EJ6+BR;EI1+BM;;IAEE,mBAAA;EJ4+BR;AACF;AKpiCQ;EAOI,0BAAA;ALgiCZ;;AKviCQ;EAOI,gCAAA;ALoiCZ;;AK3iCQ;EAOI,yBAAA;ALwiCZ;;AK/iCQ;EAOI,wBAAA;AL4iCZ;;AKnjCQ;EAOI,+BAAA;ALgjCZ;;AKvjCQ;EAOI,yBAAA;ALojCZ;;AK3jCQ;EAOI,6BAAA;ALwjCZ;;AK/jCQ;EAOI,8BAAA;AL4jCZ;;AKnkCQ;EAOI,wBAAA;ALgkCZ;;AKvkCQ;EAOI,+BAAA;ALokCZ;;AK3kCQ;EAOI,wBAAA;ALwkCZ;;AK/kCQ;EAOI,yBAAA;AL4kCZ;;AKnlCQ;EAOI,8BAAA;ALglCZ;;AKvlCQ;EAOI,iCAAA;ALolCZ;;AK3lCQ;EAOI,sCAAA;ALwlCZ;;AK/lCQ;EAOI,yCAAA;AL4lCZ;;AKnmCQ;EAOI,uBAAA;ALgmCZ;;AKvmCQ;EAOI,uBAAA;ALomCZ;;AK3mCQ;EAOI,yBAAA;ALwmCZ;;AK/mCQ;EAOI,yBAAA;AL4mCZ;;AKnnCQ;EAOI,0BAAA;ALgnCZ;;AKvnCQ;EAOI,4BAAA;ALonCZ;;AK3nCQ;EAOI,kCAAA;ALwnCZ;;AK/nCQ;EAOI,sCAAA;AL4nCZ;;AKnoCQ;EAOI,oCAAA;ALgoCZ;;AKvoCQ;EAOI,kCAAA;ALooCZ;;AK3oCQ;EAOI,yCAAA;ALwoCZ;;AK/oCQ;EAOI,wCAAA;AL4oCZ;;AKnpCQ;EAOI,wCAAA;ALgpCZ;;AKvpCQ;EAOI,kCAAA;ALopCZ;;AK3pCQ;EAOI,gCAAA;ALwpCZ;;AK/pCQ;EAOI,8BAAA;AL4pCZ;;AKnqCQ;EAOI,gCAAA;ALgqCZ;;AKvqCQ;EAOI,+BAAA;ALoqCZ;;AK3qCQ;EAOI,oCAAA;ALwqCZ;;AK/qCQ;EAOI,kCAAA;AL4qCZ;;AKnrCQ;EAOI,gCAAA;ALgrCZ;;AKvrCQ;EAOI,uCAAA;ALorCZ;;AK3rCQ;EAOI,sCAAA;ALwrCZ;;AK/rCQ;EAOI,iCAAA;AL4rCZ;;AKnsCQ;EAOI,2BAAA;ALgsCZ;;AKvsCQ;EAOI,iCAAA;ALosCZ;;AK3sCQ;EAOI,+BAAA;ALwsCZ;;AK/sCQ;EAOI,6BAAA;AL4sCZ;;AKntCQ;EAOI,+BAAA;ALgtCZ;;AKvtCQ;EAOI,8BAAA;ALotCZ;;AK3tCQ;EAOI,oBAAA;ALwtCZ;;AK/tCQ;EAOI,mBAAA;AL4tCZ;;AKnuCQ;EAOI,mBAAA;ALguCZ;;AKvuCQ;EAOI,mBAAA;ALouCZ;;AK3uCQ;EAOI,mBAAA;ALwuCZ;;AK/uCQ;EAOI,mBAAA;AL4uCZ;;AKnvCQ;EAOI,mBAAA;ALgvCZ;;AKvvCQ;EAOI,mBAAA;ALovCZ;;AK3vCQ;EAOI,oBAAA;ALwvCZ;;AK/vCQ;EAOI,0BAAA;AL4vCZ;;AKnwCQ;EAOI,yBAAA;ALgwCZ;;AKvwCQ;EAOI,uBAAA;ALowCZ;;AK3wCQ;EAOI,yBAAA;ALwwCZ;;AK/wCQ;EAOI,uBAAA;AL4wCZ;;AKnxCQ;EAOI,uBAAA;ALgxCZ;;AKvxCQ;EAOI,yBAAA;EAAA,0BAAA;ALqxCZ;;AK5xCQ;EAOI,+BAAA;EAAA,gCAAA;AL0xCZ;;AKjyCQ;EAOI,8BAAA;EAAA,+BAAA;AL+xCZ;;AKtyCQ;EAOI,4BAAA;EAAA,6BAAA;ALoyCZ;;AK3yCQ;EAOI,8BAAA;EAAA,+BAAA;ALyyCZ;;AKhzCQ;EAOI,4BAAA;EAAA,6BAAA;AL8yCZ;;AKrzCQ;EAOI,4BAAA;EAAA,6BAAA;ALmzCZ;;AK1zCQ;EAOI,wBAAA;EAAA,2BAAA;ALwzCZ;;AK/zCQ;EAOI,8BAAA;EAAA,iCAAA;AL6zCZ;;AKp0CQ;EAOI,6BAAA;EAAA,gCAAA;ALk0CZ;;AKz0CQ;EAOI,2BAAA;EAAA,8BAAA;ALu0CZ;;AK90CQ;EAOI,6BAAA;EAAA,gCAAA;AL40CZ;;AKn1CQ;EAOI,2BAAA;EAAA,8BAAA;ALi1CZ;;AKx1CQ;EAOI,2BAAA;EAAA,8BAAA;ALs1CZ;;AK71CQ;EAOI,wBAAA;AL01CZ;;AKj2CQ;EAOI,8BAAA;AL81CZ;;AKr2CQ;EAOI,6BAAA;ALk2CZ;;AKz2CQ;EAOI,2BAAA;ALs2CZ;;AK72CQ;EAOI,6BAAA;AL02CZ;;AKj3CQ;EAOI,2BAAA;AL82CZ;;AKr3CQ;EAOI,2BAAA;ALk3CZ;;AKz3CQ;EAOI,yBAAA;ALs3CZ;;AK73CQ;EAOI,+BAAA;AL03CZ;;AKj4CQ;EAOI,8BAAA;AL83CZ;;AKr4CQ;EAOI,4BAAA;ALk4CZ;;AKz4CQ;EAOI,8BAAA;ALs4CZ;;AK74CQ;EAOI,4BAAA;AL04CZ;;AKj5CQ;EAOI,4BAAA;AL84CZ;;AKr5CQ;EAOI,2BAAA;ALk5CZ;;AKz5CQ;EAOI,iCAAA;ALs5CZ;;AK75CQ;EAOI,gCAAA;AL05CZ;;AKj6CQ;EAOI,8BAAA;AL85CZ;;AKr6CQ;EAOI,gCAAA;ALk6CZ;;AKz6CQ;EAOI,8BAAA;ALs6CZ;;AK76CQ;EAOI,8BAAA;AL06CZ;;AKj7CQ;EAOI,0BAAA;AL86CZ;;AKr7CQ;EAOI,gCAAA;ALk7CZ;;AKz7CQ;EAOI,+BAAA;ALs7CZ;;AK77CQ;EAOI,6BAAA;AL07CZ;;AKj8CQ;EAOI,+BAAA;AL87CZ;;AKr8CQ;EAOI,6BAAA;ALk8CZ;;AKz8CQ;EAOI,6BAAA;ALs8CZ;;AK78CQ;EAOI,qBAAA;AL08CZ;;AKj9CQ;EAOI,2BAAA;AL88CZ;;AKr9CQ;EAOI,0BAAA;ALk9CZ;;AKz9CQ;EAOI,wBAAA;ALs9CZ;;AK79CQ;EAOI,0BAAA;AL09CZ;;AKj+CQ;EAOI,wBAAA;AL89CZ;;AKr+CQ;EAOI,0BAAA;EAAA,2BAAA;ALm+CZ;;AK1+CQ;EAOI,gCAAA;EAAA,iCAAA;ALw+CZ;;AK/+CQ;EAOI,+BAAA;EAAA,gCAAA;AL6+CZ;;AKp/CQ;EAOI,6BAAA;EAAA,8BAAA;ALk/CZ;;AKz/CQ;EAOI,+BAAA;EAAA,gCAAA;ALu/CZ;;AK9/CQ;EAOI,6BAAA;EAAA,8BAAA;AL4/CZ;;AKngDQ;EAOI,yBAAA;EAAA,4BAAA;ALigDZ;;AKxgDQ;EAOI,+BAAA;EAAA,kCAAA;ALsgDZ;;AK7gDQ;EAOI,8BAAA;EAAA,iCAAA;AL2gDZ;;AKlhDQ;EAOI,4BAAA;EAAA,+BAAA;ALghDZ;;AKvhDQ;EAOI,8BAAA;EAAA,iCAAA;ALqhDZ;;AK5hDQ;EAOI,4BAAA;EAAA,+BAAA;AL0hDZ;;AKjiDQ;EAOI,yBAAA;AL8hDZ;;AKriDQ;EAOI,+BAAA;ALkiDZ;;AKziDQ;EAOI,8BAAA;ALsiDZ;;AK7iDQ;EAOI,4BAAA;AL0iDZ;;AKjjDQ;EAOI,8BAAA;AL8iDZ;;AKrjDQ;EAOI,4BAAA;ALkjDZ;;AKzjDQ;EAOI,0BAAA;ALsjDZ;;AK7jDQ;EAOI,gCAAA;AL0jDZ;;AKjkDQ;EAOI,+BAAA;AL8jDZ;;AKrkDQ;EAOI,6BAAA;ALkkDZ;;AKzkDQ;EAOI,+BAAA;ALskDZ;;AK7kDQ;EAOI,6BAAA;AL0kDZ;;AKjlDQ;EAOI,4BAAA;AL8kDZ;;AKrlDQ;EAOI,kCAAA;ALklDZ;;AKzlDQ;EAOI,iCAAA;ALslDZ;;AK7lDQ;EAOI,+BAAA;AL0lDZ;;AKjmDQ;EAOI,iCAAA;AL8lDZ;;AKrmDQ;EAOI,+BAAA;ALkmDZ;;AKzmDQ;EAOI,2BAAA;ALsmDZ;;AK7mDQ;EAOI,iCAAA;AL0mDZ;;AKjnDQ;EAOI,gCAAA;AL8mDZ;;AKrnDQ;EAOI,8BAAA;ALknDZ;;AKznDQ;EAOI,gCAAA;ALsnDZ;;AK7nDQ;EAOI,8BAAA;AL0nDZ;;ACpoDI;EIGI;IAOI,0BAAA;EL+nDV;EKtoDM;IAOI,gCAAA;ELkoDV;EKzoDM;IAOI,yBAAA;ELqoDV;EK5oDM;IAOI,wBAAA;ELwoDV;EK/oDM;IAOI,+BAAA;EL2oDV;EKlpDM;IAOI,yBAAA;EL8oDV;EKrpDM;IAOI,6BAAA;ELipDV;EKxpDM;IAOI,8BAAA;ELopDV;EK3pDM;IAOI,wBAAA;ELupDV;EK9pDM;IAOI,+BAAA;EL0pDV;EKjqDM;IAOI,wBAAA;EL6pDV;EKpqDM;IAOI,yBAAA;ELgqDV;EKvqDM;IAOI,8BAAA;ELmqDV;EK1qDM;IAOI,iCAAA;ELsqDV;EK7qDM;IAOI,sCAAA;ELyqDV;EKhrDM;IAOI,yCAAA;EL4qDV;EKnrDM;IAOI,uBAAA;EL+qDV;EKtrDM;IAOI,uBAAA;ELkrDV;EKzrDM;IAOI,yBAAA;ELqrDV;EK5rDM;IAOI,yBAAA;ELwrDV;EK/rDM;IAOI,0BAAA;EL2rDV;EKlsDM;IAOI,4BAAA;EL8rDV;EKrsDM;IAOI,kCAAA;ELisDV;EKxsDM;IAOI,sCAAA;ELosDV;EK3sDM;IAOI,oCAAA;ELusDV;EK9sDM;IAOI,kCAAA;EL0sDV;EKjtDM;IAOI,yCAAA;EL6sDV;EKptDM;IAOI,wCAAA;ELgtDV;EKvtDM;IAOI,wCAAA;ELmtDV;EK1tDM;IAOI,kCAAA;ELstDV;EK7tDM;IAOI,gCAAA;ELytDV;EKhuDM;IAOI,8BAAA;EL4tDV;EKnuDM;IAOI,gCAAA;EL+tDV;EKtuDM;IAOI,+BAAA;ELkuDV;EKzuDM;IAOI,oCAAA;ELquDV;EK5uDM;IAOI,kCAAA;ELwuDV;EK/uDM;IAOI,gCAAA;EL2uDV;EKlvDM;IAOI,uCAAA;EL8uDV;EKrvDM;IAOI,sCAAA;ELivDV;EKxvDM;IAOI,iCAAA;ELovDV;EK3vDM;IAOI,2BAAA;ELuvDV;EK9vDM;IAOI,iCAAA;EL0vDV;EKjwDM;IAOI,+BAAA;EL6vDV;EKpwDM;IAOI,6BAAA;ELgwDV;EKvwDM;IAOI,+BAAA;ELmwDV;EK1wDM;IAOI,8BAAA;ELswDV;EK7wDM;IAOI,oBAAA;ELywDV;EKhxDM;IAOI,mBAAA;EL4wDV;EKnxDM;IAOI,mBAAA;EL+wDV;EKtxDM;IAOI,mBAAA;ELkxDV;EKzxDM;IAOI,mBAAA;ELqxDV;EK5xDM;IAOI,mBAAA;ELwxDV;EK/xDM;IAOI,mBAAA;EL2xDV;EKlyDM;IAOI,mBAAA;EL8xDV;EKryDM;IAOI,oBAAA;ELiyDV;EKxyDM;IAOI,0BAAA;ELoyDV;EK3yDM;IAOI,yBAAA;ELuyDV;EK9yDM;IAOI,uBAAA;EL0yDV;EKjzDM;IAOI,yBAAA;EL6yDV;EKpzDM;IAOI,uBAAA;ELgzDV;EKvzDM;IAOI,uBAAA;ELmzDV;EK1zDM;IAOI,yBAAA;IAAA,0BAAA;ELuzDV;EK9zDM;IAOI,+BAAA;IAAA,gCAAA;EL2zDV;EKl0DM;IAOI,8BAAA;IAAA,+BAAA;EL+zDV;EKt0DM;IAOI,4BAAA;IAAA,6BAAA;ELm0DV;EK10DM;IAOI,8BAAA;IAAA,+BAAA;ELu0DV;EK90DM;IAOI,4BAAA;IAAA,6BAAA;EL20DV;EKl1DM;IAOI,4BAAA;IAAA,6BAAA;EL+0DV;EKt1DM;IAOI,wBAAA;IAAA,2BAAA;ELm1DV;EK11DM;IAOI,8BAAA;IAAA,iCAAA;ELu1DV;EK91DM;IAOI,6BAAA;IAAA,gCAAA;EL21DV;EKl2DM;IAOI,2BAAA;IAAA,8BAAA;EL+1DV;EKt2DM;IAOI,6BAAA;IAAA,gCAAA;ELm2DV;EK12DM;IAOI,2BAAA;IAAA,8BAAA;ELu2DV;EK92DM;IAOI,2BAAA;IAAA,8BAAA;EL22DV;EKl3DM;IAOI,wBAAA;EL82DV;EKr3DM;IAOI,8BAAA;ELi3DV;EKx3DM;IAOI,6BAAA;ELo3DV;EK33DM;IAOI,2BAAA;ELu3DV;EK93DM;IAOI,6BAAA;EL03DV;EKj4DM;IAOI,2BAAA;EL63DV;EKp4DM;IAOI,2BAAA;ELg4DV;EKv4DM;IAOI,yBAAA;ELm4DV;EK14DM;IAOI,+BAAA;ELs4DV;EK74DM;IAOI,8BAAA;ELy4DV;EKh5DM;IAOI,4BAAA;EL44DV;EKn5DM;IAOI,8BAAA;EL+4DV;EKt5DM;IAOI,4BAAA;ELk5DV;EKz5DM;IAOI,4BAAA;ELq5DV;EK55DM;IAOI,2BAAA;ELw5DV;EK/5DM;IAOI,iCAAA;EL25DV;EKl6DM;IAOI,gCAAA;EL85DV;EKr6DM;IAOI,8BAAA;ELi6DV;EKx6DM;IAOI,gCAAA;ELo6DV;EK36DM;IAOI,8BAAA;ELu6DV;EK96DM;IAOI,8BAAA;EL06DV;EKj7DM;IAOI,0BAAA;EL66DV;EKp7DM;IAOI,gCAAA;ELg7DV;EKv7DM;IAOI,+BAAA;ELm7DV;EK17DM;IAOI,6BAAA;ELs7DV;EK77DM;IAOI,+BAAA;ELy7DV;EKh8DM;IAOI,6BAAA;EL47DV;EKn8DM;IAOI,6BAAA;EL+7DV;EKt8DM;IAOI,qBAAA;ELk8DV;EKz8DM;IAOI,2BAAA;ELq8DV;EK58DM;IAOI,0BAAA;ELw8DV;EK/8DM;IAOI,wBAAA;EL28DV;EKl9DM;IAOI,0BAAA;EL88DV;EKr9DM;IAOI,wBAAA;ELi9DV;EKx9DM;IAOI,0BAAA;IAAA,2BAAA;ELq9DV;EK59DM;IAOI,gCAAA;IAAA,iCAAA;ELy9DV;EKh+DM;IAOI,+BAAA;IAAA,gCAAA;EL69DV;EKp+DM;IAOI,6BAAA;IAAA,8BAAA;ELi+DV;EKx+DM;IAOI,+BAAA;IAAA,gCAAA;ELq+DV;EK5+DM;IAOI,6BAAA;IAAA,8BAAA;ELy+DV;EKh/DM;IAOI,yBAAA;IAAA,4BAAA;EL6+DV;EKp/DM;IAOI,+BAAA;IAAA,kCAAA;ELi/DV;EKx/DM;IAOI,8BAAA;IAAA,iCAAA;ELq/DV;EK5/DM;IAOI,4BAAA;IAAA,+BAAA;ELy/DV;EKhgEM;IAOI,8BAAA;IAAA,iCAAA;EL6/DV;EKpgEM;IAOI,4BAAA;IAAA,+BAAA;ELigEV;EKxgEM;IAOI,yBAAA;ELogEV;EK3gEM;IAOI,+BAAA;ELugEV;EK9gEM;IAOI,8BAAA;EL0gEV;EKjhEM;IAOI,4BAAA;EL6gEV;EKphEM;IAOI,8BAAA;ELghEV;EKvhEM;IAOI,4BAAA;ELmhEV;EK1hEM;IAOI,0BAAA;ELshEV;EK7hEM;IAOI,gCAAA;ELyhEV;EKhiEM;IAOI,+BAAA;EL4hEV;EKniEM;IAOI,6BAAA;EL+hEV;EKtiEM;IAOI,+BAAA;ELkiEV;EKziEM;IAOI,6BAAA;ELqiEV;EK5iEM;IAOI,4BAAA;ELwiEV;EK/iEM;IAOI,kCAAA;EL2iEV;EKljEM;IAOI,iCAAA;EL8iEV;EKrjEM;IAOI,+BAAA;ELijEV;EKxjEM;IAOI,iCAAA;ELojEV;EK3jEM;IAOI,+BAAA;ELujEV;EK9jEM;IAOI,2BAAA;EL0jEV;EKjkEM;IAOI,iCAAA;EL6jEV;EKpkEM;IAOI,gCAAA;ELgkEV;EKvkEM;IAOI,8BAAA;ELmkEV;EK1kEM;IAOI,gCAAA;ELskEV;EK7kEM;IAOI,8BAAA;ELykEV;AACF;ACplEI;EIGI;IAOI,0BAAA;EL8kEV;EKrlEM;IAOI,gCAAA;ELilEV;EKxlEM;IAOI,yBAAA;ELolEV;EK3lEM;IAOI,wBAAA;ELulEV;EK9lEM;IAOI,+BAAA;EL0lEV;EKjmEM;IAOI,yBAAA;EL6lEV;EKpmEM;IAOI,6BAAA;ELgmEV;EKvmEM;IAOI,8BAAA;ELmmEV;EK1mEM;IAOI,wBAAA;ELsmEV;EK7mEM;IAOI,+BAAA;ELymEV;EKhnEM;IAOI,wBAAA;EL4mEV;EKnnEM;IAOI,yBAAA;EL+mEV;EKtnEM;IAOI,8BAAA;ELknEV;EKznEM;IAOI,iCAAA;ELqnEV;EK5nEM;IAOI,sCAAA;ELwnEV;EK/nEM;IAOI,yCAAA;EL2nEV;EKloEM;IAOI,uBAAA;EL8nEV;EKroEM;IAOI,uBAAA;ELioEV;EKxoEM;IAOI,yBAAA;ELooEV;EK3oEM;IAOI,yBAAA;ELuoEV;EK9oEM;IAOI,0BAAA;EL0oEV;EKjpEM;IAOI,4BAAA;EL6oEV;EKppEM;IAOI,kCAAA;ELgpEV;EKvpEM;IAOI,sCAAA;ELmpEV;EK1pEM;IAOI,oCAAA;ELspEV;EK7pEM;IAOI,kCAAA;ELypEV;EKhqEM;IAOI,yCAAA;EL4pEV;EKnqEM;IAOI,wCAAA;EL+pEV;EKtqEM;IAOI,wCAAA;ELkqEV;EKzqEM;IAOI,kCAAA;ELqqEV;EK5qEM;IAOI,gCAAA;ELwqEV;EK/qEM;IAOI,8BAAA;EL2qEV;EKlrEM;IAOI,gCAAA;EL8qEV;EKrrEM;IAOI,+BAAA;ELirEV;EKxrEM;IAOI,oCAAA;ELorEV;EK3rEM;IAOI,kCAAA;ELurEV;EK9rEM;IAOI,gCAAA;EL0rEV;EKjsEM;IAOI,uCAAA;EL6rEV;EKpsEM;IAOI,sCAAA;ELgsEV;EKvsEM;IAOI,iCAAA;ELmsEV;EK1sEM;IAOI,2BAAA;ELssEV;EK7sEM;IAOI,iCAAA;ELysEV;EKhtEM;IAOI,+BAAA;EL4sEV;EKntEM;IAOI,6BAAA;EL+sEV;EKttEM;IAOI,+BAAA;ELktEV;EKztEM;IAOI,8BAAA;ELqtEV;EK5tEM;IAOI,oBAAA;ELwtEV;EK/tEM;IAOI,mBAAA;EL2tEV;EKluEM;IAOI,mBAAA;EL8tEV;EKruEM;IAOI,mBAAA;ELiuEV;EKxuEM;IAOI,mBAAA;ELouEV;EK3uEM;IAOI,mBAAA;ELuuEV;EK9uEM;IAOI,mBAAA;EL0uEV;EKjvEM;IAOI,mBAAA;EL6uEV;EKpvEM;IAOI,oBAAA;ELgvEV;EKvvEM;IAOI,0BAAA;ELmvEV;EK1vEM;IAOI,yBAAA;ELsvEV;EK7vEM;IAOI,uBAAA;ELyvEV;EKhwEM;IAOI,yBAAA;EL4vEV;EKnwEM;IAOI,uBAAA;EL+vEV;EKtwEM;IAOI,uBAAA;ELkwEV;EKzwEM;IAOI,yBAAA;IAAA,0BAAA;ELswEV;EK7wEM;IAOI,+BAAA;IAAA,gCAAA;EL0wEV;EKjxEM;IAOI,8BAAA;IAAA,+BAAA;EL8wEV;EKrxEM;IAOI,4BAAA;IAAA,6BAAA;ELkxEV;EKzxEM;IAOI,8BAAA;IAAA,+BAAA;ELsxEV;EK7xEM;IAOI,4BAAA;IAAA,6BAAA;EL0xEV;EKjyEM;IAOI,4BAAA;IAAA,6BAAA;EL8xEV;EKryEM;IAOI,wBAAA;IAAA,2BAAA;ELkyEV;EKzyEM;IAOI,8BAAA;IAAA,iCAAA;ELsyEV;EK7yEM;IAOI,6BAAA;IAAA,gCAAA;EL0yEV;EKjzEM;IAOI,2BAAA;IAAA,8BAAA;EL8yEV;EKrzEM;IAOI,6BAAA;IAAA,gCAAA;ELkzEV;EKzzEM;IAOI,2BAAA;IAAA,8BAAA;ELszEV;EK7zEM;IAOI,2BAAA;IAAA,8BAAA;EL0zEV;EKj0EM;IAOI,wBAAA;EL6zEV;EKp0EM;IAOI,8BAAA;ELg0EV;EKv0EM;IAOI,6BAAA;ELm0EV;EK10EM;IAOI,2BAAA;ELs0EV;EK70EM;IAOI,6BAAA;ELy0EV;EKh1EM;IAOI,2BAAA;EL40EV;EKn1EM;IAOI,2BAAA;EL+0EV;EKt1EM;IAOI,yBAAA;ELk1EV;EKz1EM;IAOI,+BAAA;ELq1EV;EK51EM;IAOI,8BAAA;ELw1EV;EK/1EM;IAOI,4BAAA;EL21EV;EKl2EM;IAOI,8BAAA;EL81EV;EKr2EM;IAOI,4BAAA;ELi2EV;EKx2EM;IAOI,4BAAA;ELo2EV;EK32EM;IAOI,2BAAA;ELu2EV;EK92EM;IAOI,iCAAA;EL02EV;EKj3EM;IAOI,gCAAA;EL62EV;EKp3EM;IAOI,8BAAA;ELg3EV;EKv3EM;IAOI,gCAAA;ELm3EV;EK13EM;IAOI,8BAAA;ELs3EV;EK73EM;IAOI,8BAAA;ELy3EV;EKh4EM;IAOI,0BAAA;EL43EV;EKn4EM;IAOI,gCAAA;EL+3EV;EKt4EM;IAOI,+BAAA;ELk4EV;EKz4EM;IAOI,6BAAA;ELq4EV;EK54EM;IAOI,+BAAA;ELw4EV;EK/4EM;IAOI,6BAAA;EL24EV;EKl5EM;IAOI,6BAAA;EL84EV;EKr5EM;IAOI,qBAAA;ELi5EV;EKx5EM;IAOI,2BAAA;ELo5EV;EK35EM;IAOI,0BAAA;ELu5EV;EK95EM;IAOI,wBAAA;EL05EV;EKj6EM;IAOI,0BAAA;EL65EV;EKp6EM;IAOI,wBAAA;ELg6EV;EKv6EM;IAOI,0BAAA;IAAA,2BAAA;ELo6EV;EK36EM;IAOI,gCAAA;IAAA,iCAAA;ELw6EV;EK/6EM;IAOI,+BAAA;IAAA,gCAAA;EL46EV;EKn7EM;IAOI,6BAAA;IAAA,8BAAA;ELg7EV;EKv7EM;IAOI,+BAAA;IAAA,gCAAA;ELo7EV;EK37EM;IAOI,6BAAA;IAAA,8BAAA;ELw7EV;EK/7EM;IAOI,yBAAA;IAAA,4BAAA;EL47EV;EKn8EM;IAOI,+BAAA;IAAA,kCAAA;ELg8EV;EKv8EM;IAOI,8BAAA;IAAA,iCAAA;ELo8EV;EK38EM;IAOI,4BAAA;IAAA,+BAAA;ELw8EV;EK/8EM;IAOI,8BAAA;IAAA,iCAAA;EL48EV;EKn9EM;IAOI,4BAAA;IAAA,+BAAA;ELg9EV;EKv9EM;IAOI,yBAAA;ELm9EV;EK19EM;IAOI,+BAAA;ELs9EV;EK79EM;IAOI,8BAAA;ELy9EV;EKh+EM;IAOI,4BAAA;EL49EV;EKn+EM;IAOI,8BAAA;EL+9EV;EKt+EM;IAOI,4BAAA;ELk+EV;EKz+EM;IAOI,0BAAA;ELq+EV;EK5+EM;IAOI,gCAAA;ELw+EV;EK/+EM;IAOI,+BAAA;EL2+EV;EKl/EM;IAOI,6BAAA;EL8+EV;EKr/EM;IAOI,+BAAA;ELi/EV;EKx/EM;IAOI,6BAAA;ELo/EV;EK3/EM;IAOI,4BAAA;ELu/EV;EK9/EM;IAOI,kCAAA;EL0/EV;EKjgFM;IAOI,iCAAA;EL6/EV;EKpgFM;IAOI,+BAAA;ELggFV;EKvgFM;IAOI,iCAAA;ELmgFV;EK1gFM;IAOI,+BAAA;ELsgFV;EK7gFM;IAOI,2BAAA;ELygFV;EKhhFM;IAOI,iCAAA;EL4gFV;EKnhFM;IAOI,gCAAA;EL+gFV;EKthFM;IAOI,8BAAA;ELkhFV;EKzhFM;IAOI,gCAAA;ELqhFV;EK5hFM;IAOI,8BAAA;ELwhFV;AACF;ACniFI;EIGI;IAOI,0BAAA;EL6hFV;EKpiFM;IAOI,gCAAA;ELgiFV;EKviFM;IAOI,yBAAA;ELmiFV;EK1iFM;IAOI,wBAAA;ELsiFV;EK7iFM;IAOI,+BAAA;ELyiFV;EKhjFM;IAOI,yBAAA;EL4iFV;EKnjFM;IAOI,6BAAA;EL+iFV;EKtjFM;IAOI,8BAAA;ELkjFV;EKzjFM;IAOI,wBAAA;ELqjFV;EK5jFM;IAOI,+BAAA;ELwjFV;EK/jFM;IAOI,wBAAA;EL2jFV;EKlkFM;IAOI,yBAAA;EL8jFV;EKrkFM;IAOI,8BAAA;ELikFV;EKxkFM;IAOI,iCAAA;ELokFV;EK3kFM;IAOI,sCAAA;ELukFV;EK9kFM;IAOI,yCAAA;EL0kFV;EKjlFM;IAOI,uBAAA;EL6kFV;EKplFM;IAOI,uBAAA;ELglFV;EKvlFM;IAOI,yBAAA;ELmlFV;EK1lFM;IAOI,yBAAA;ELslFV;EK7lFM;IAOI,0BAAA;ELylFV;EKhmFM;IAOI,4BAAA;EL4lFV;EKnmFM;IAOI,kCAAA;EL+lFV;EKtmFM;IAOI,sCAAA;ELkmFV;EKzmFM;IAOI,oCAAA;ELqmFV;EK5mFM;IAOI,kCAAA;ELwmFV;EK/mFM;IAOI,yCAAA;EL2mFV;EKlnFM;IAOI,wCAAA;EL8mFV;EKrnFM;IAOI,wCAAA;ELinFV;EKxnFM;IAOI,kCAAA;ELonFV;EK3nFM;IAOI,gCAAA;ELunFV;EK9nFM;IAOI,8BAAA;EL0nFV;EKjoFM;IAOI,gCAAA;EL6nFV;EKpoFM;IAOI,+BAAA;ELgoFV;EKvoFM;IAOI,oCAAA;ELmoFV;EK1oFM;IAOI,kCAAA;ELsoFV;EK7oFM;IAOI,gCAAA;ELyoFV;EKhpFM;IAOI,uCAAA;EL4oFV;EKnpFM;IAOI,sCAAA;EL+oFV;EKtpFM;IAOI,iCAAA;ELkpFV;EKzpFM;IAOI,2BAAA;ELqpFV;EK5pFM;IAOI,iCAAA;ELwpFV;EK/pFM;IAOI,+BAAA;EL2pFV;EKlqFM;IAOI,6BAAA;EL8pFV;EKrqFM;IAOI,+BAAA;ELiqFV;EKxqFM;IAOI,8BAAA;ELoqFV;EK3qFM;IAOI,oBAAA;ELuqFV;EK9qFM;IAOI,mBAAA;EL0qFV;EKjrFM;IAOI,mBAAA;EL6qFV;EKprFM;IAOI,mBAAA;ELgrFV;EKvrFM;IAOI,mBAAA;ELmrFV;EK1rFM;IAOI,mBAAA;ELsrFV;EK7rFM;IAOI,mBAAA;ELyrFV;EKhsFM;IAOI,mBAAA;EL4rFV;EKnsFM;IAOI,oBAAA;EL+rFV;EKtsFM;IAOI,0BAAA;ELksFV;EKzsFM;IAOI,yBAAA;ELqsFV;EK5sFM;IAOI,uBAAA;ELwsFV;EK/sFM;IAOI,yBAAA;EL2sFV;EKltFM;IAOI,uBAAA;EL8sFV;EKrtFM;IAOI,uBAAA;ELitFV;EKxtFM;IAOI,yBAAA;IAAA,0BAAA;ELqtFV;EK5tFM;IAOI,+BAAA;IAAA,gCAAA;ELytFV;EKhuFM;IAOI,8BAAA;IAAA,+BAAA;EL6tFV;EKpuFM;IAOI,4BAAA;IAAA,6BAAA;ELiuFV;EKxuFM;IAOI,8BAAA;IAAA,+BAAA;ELquFV;EK5uFM;IAOI,4BAAA;IAAA,6BAAA;ELyuFV;EKhvFM;IAOI,4BAAA;IAAA,6BAAA;EL6uFV;EKpvFM;IAOI,wBAAA;IAAA,2BAAA;ELivFV;EKxvFM;IAOI,8BAAA;IAAA,iCAAA;ELqvFV;EK5vFM;IAOI,6BAAA;IAAA,gCAAA;ELyvFV;EKhwFM;IAOI,2BAAA;IAAA,8BAAA;EL6vFV;EKpwFM;IAOI,6BAAA;IAAA,gCAAA;ELiwFV;EKxwFM;IAOI,2BAAA;IAAA,8BAAA;ELqwFV;EK5wFM;IAOI,2BAAA;IAAA,8BAAA;ELywFV;EKhxFM;IAOI,wBAAA;EL4wFV;EKnxFM;IAOI,8BAAA;EL+wFV;EKtxFM;IAOI,6BAAA;ELkxFV;EKzxFM;IAOI,2BAAA;ELqxFV;EK5xFM;IAOI,6BAAA;ELwxFV;EK/xFM;IAOI,2BAAA;EL2xFV;EKlyFM;IAOI,2BAAA;EL8xFV;EKryFM;IAOI,yBAAA;ELiyFV;EKxyFM;IAOI,+BAAA;ELoyFV;EK3yFM;IAOI,8BAAA;ELuyFV;EK9yFM;IAOI,4BAAA;EL0yFV;EKjzFM;IAOI,8BAAA;EL6yFV;EKpzFM;IAOI,4BAAA;ELgzFV;EKvzFM;IAOI,4BAAA;ELmzFV;EK1zFM;IAOI,2BAAA;ELszFV;EK7zFM;IAOI,iCAAA;ELyzFV;EKh0FM;IAOI,gCAAA;EL4zFV;EKn0FM;IAOI,8BAAA;EL+zFV;EKt0FM;IAOI,gCAAA;ELk0FV;EKz0FM;IAOI,8BAAA;ELq0FV;EK50FM;IAOI,8BAAA;ELw0FV;EK/0FM;IAOI,0BAAA;EL20FV;EKl1FM;IAOI,gCAAA;EL80FV;EKr1FM;IAOI,+BAAA;ELi1FV;EKx1FM;IAOI,6BAAA;ELo1FV;EK31FM;IAOI,+BAAA;ELu1FV;EK91FM;IAOI,6BAAA;EL01FV;EKj2FM;IAOI,6BAAA;EL61FV;EKp2FM;IAOI,qBAAA;ELg2FV;EKv2FM;IAOI,2BAAA;ELm2FV;EK12FM;IAOI,0BAAA;ELs2FV;EK72FM;IAOI,wBAAA;ELy2FV;EKh3FM;IAOI,0BAAA;EL42FV;EKn3FM;IAOI,wBAAA;EL+2FV;EKt3FM;IAOI,0BAAA;IAAA,2BAAA;ELm3FV;EK13FM;IAOI,gCAAA;IAAA,iCAAA;ELu3FV;EK93FM;IAOI,+BAAA;IAAA,gCAAA;EL23FV;EKl4FM;IAOI,6BAAA;IAAA,8BAAA;EL+3FV;EKt4FM;IAOI,+BAAA;IAAA,gCAAA;ELm4FV;EK14FM;IAOI,6BAAA;IAAA,8BAAA;ELu4FV;EK94FM;IAOI,yBAAA;IAAA,4BAAA;EL24FV;EKl5FM;IAOI,+BAAA;IAAA,kCAAA;EL+4FV;EKt5FM;IAOI,8BAAA;IAAA,iCAAA;ELm5FV;EK15FM;IAOI,4BAAA;IAAA,+BAAA;ELu5FV;EK95FM;IAOI,8BAAA;IAAA,iCAAA;EL25FV;EKl6FM;IAOI,4BAAA;IAAA,+BAAA;EL+5FV;EKt6FM;IAOI,yBAAA;ELk6FV;EKz6FM;IAOI,+BAAA;ELq6FV;EK56FM;IAOI,8BAAA;ELw6FV;EK/6FM;IAOI,4BAAA;EL26FV;EKl7FM;IAOI,8BAAA;EL86FV;EKr7FM;IAOI,4BAAA;ELi7FV;EKx7FM;IAOI,0BAAA;ELo7FV;EK37FM;IAOI,gCAAA;ELu7FV;EK97FM;IAOI,+BAAA;EL07FV;EKj8FM;IAOI,6BAAA;EL67FV;EKp8FM;IAOI,+BAAA;ELg8FV;EKv8FM;IAOI,6BAAA;ELm8FV;EK18FM;IAOI,4BAAA;ELs8FV;EK78FM;IAOI,kCAAA;ELy8FV;EKh9FM;IAOI,iCAAA;EL48FV;EKn9FM;IAOI,+BAAA;EL+8FV;EKt9FM;IAOI,iCAAA;ELk9FV;EKz9FM;IAOI,+BAAA;ELq9FV;EK59FM;IAOI,2BAAA;ELw9FV;EK/9FM;IAOI,iCAAA;EL29FV;EKl+FM;IAOI,gCAAA;EL89FV;EKr+FM;IAOI,8BAAA;ELi+FV;EKx+FM;IAOI,gCAAA;ELo+FV;EK3+FM;IAOI,8BAAA;ELu+FV;AACF;ACl/FI;EIGI;IAOI,0BAAA;EL4+FV;EKn/FM;IAOI,gCAAA;EL++FV;EKt/FM;IAOI,yBAAA;ELk/FV;EKz/FM;IAOI,wBAAA;ELq/FV;EK5/FM;IAOI,+BAAA;ELw/FV;EK//FM;IAOI,yBAAA;EL2/FV;EKlgGM;IAOI,6BAAA;EL8/FV;EKrgGM;IAOI,8BAAA;ELigGV;EKxgGM;IAOI,wBAAA;ELogGV;EK3gGM;IAOI,+BAAA;ELugGV;EK9gGM;IAOI,wBAAA;EL0gGV;EKjhGM;IAOI,yBAAA;EL6gGV;EKphGM;IAOI,8BAAA;ELghGV;EKvhGM;IAOI,iCAAA;ELmhGV;EK1hGM;IAOI,sCAAA;ELshGV;EK7hGM;IAOI,yCAAA;ELyhGV;EKhiGM;IAOI,uBAAA;EL4hGV;EKniGM;IAOI,uBAAA;EL+hGV;EKtiGM;IAOI,yBAAA;ELkiGV;EKziGM;IAOI,yBAAA;ELqiGV;EK5iGM;IAOI,0BAAA;ELwiGV;EK/iGM;IAOI,4BAAA;EL2iGV;EKljGM;IAOI,kCAAA;EL8iGV;EKrjGM;IAOI,sCAAA;ELijGV;EKxjGM;IAOI,oCAAA;ELojGV;EK3jGM;IAOI,kCAAA;ELujGV;EK9jGM;IAOI,yCAAA;EL0jGV;EKjkGM;IAOI,wCAAA;EL6jGV;EKpkGM;IAOI,wCAAA;ELgkGV;EKvkGM;IAOI,kCAAA;ELmkGV;EK1kGM;IAOI,gCAAA;ELskGV;EK7kGM;IAOI,8BAAA;ELykGV;EKhlGM;IAOI,gCAAA;EL4kGV;EKnlGM;IAOI,+BAAA;EL+kGV;EKtlGM;IAOI,oCAAA;ELklGV;EKzlGM;IAOI,kCAAA;ELqlGV;EK5lGM;IAOI,gCAAA;ELwlGV;EK/lGM;IAOI,uCAAA;EL2lGV;EKlmGM;IAOI,sCAAA;EL8lGV;EKrmGM;IAOI,iCAAA;ELimGV;EKxmGM;IAOI,2BAAA;ELomGV;EK3mGM;IAOI,iCAAA;ELumGV;EK9mGM;IAOI,+BAAA;EL0mGV;EKjnGM;IAOI,6BAAA;EL6mGV;EKpnGM;IAOI,+BAAA;ELgnGV;EKvnGM;IAOI,8BAAA;ELmnGV;EK1nGM;IAOI,oBAAA;ELsnGV;EK7nGM;IAOI,mBAAA;ELynGV;EKhoGM;IAOI,mBAAA;EL4nGV;EKnoGM;IAOI,mBAAA;EL+nGV;EKtoGM;IAOI,mBAAA;ELkoGV;EKzoGM;IAOI,mBAAA;ELqoGV;EK5oGM;IAOI,mBAAA;ELwoGV;EK/oGM;IAOI,mBAAA;EL2oGV;EKlpGM;IAOI,oBAAA;EL8oGV;EKrpGM;IAOI,0BAAA;ELipGV;EKxpGM;IAOI,yBAAA;ELopGV;EK3pGM;IAOI,uBAAA;ELupGV;EK9pGM;IAOI,yBAAA;EL0pGV;EKjqGM;IAOI,uBAAA;EL6pGV;EKpqGM;IAOI,uBAAA;ELgqGV;EKvqGM;IAOI,yBAAA;IAAA,0BAAA;ELoqGV;EK3qGM;IAOI,+BAAA;IAAA,gCAAA;ELwqGV;EK/qGM;IAOI,8BAAA;IAAA,+BAAA;EL4qGV;EKnrGM;IAOI,4BAAA;IAAA,6BAAA;ELgrGV;EKvrGM;IAOI,8BAAA;IAAA,+BAAA;ELorGV;EK3rGM;IAOI,4BAAA;IAAA,6BAAA;ELwrGV;EK/rGM;IAOI,4BAAA;IAAA,6BAAA;EL4rGV;EKnsGM;IAOI,wBAAA;IAAA,2BAAA;ELgsGV;EKvsGM;IAOI,8BAAA;IAAA,iCAAA;ELosGV;EK3sGM;IAOI,6BAAA;IAAA,gCAAA;ELwsGV;EK/sGM;IAOI,2BAAA;IAAA,8BAAA;EL4sGV;EKntGM;IAOI,6BAAA;IAAA,gCAAA;ELgtGV;EKvtGM;IAOI,2BAAA;IAAA,8BAAA;ELotGV;EK3tGM;IAOI,2BAAA;IAAA,8BAAA;ELwtGV;EK/tGM;IAOI,wBAAA;EL2tGV;EKluGM;IAOI,8BAAA;EL8tGV;EKruGM;IAOI,6BAAA;ELiuGV;EKxuGM;IAOI,2BAAA;ELouGV;EK3uGM;IAOI,6BAAA;ELuuGV;EK9uGM;IAOI,2BAAA;EL0uGV;EKjvGM;IAOI,2BAAA;EL6uGV;EKpvGM;IAOI,yBAAA;ELgvGV;EKvvGM;IAOI,+BAAA;ELmvGV;EK1vGM;IAOI,8BAAA;ELsvGV;EK7vGM;IAOI,4BAAA;ELyvGV;EKhwGM;IAOI,8BAAA;EL4vGV;EKnwGM;IAOI,4BAAA;EL+vGV;EKtwGM;IAOI,4BAAA;ELkwGV;EKzwGM;IAOI,2BAAA;ELqwGV;EK5wGM;IAOI,iCAAA;ELwwGV;EK/wGM;IAOI,gCAAA;EL2wGV;EKlxGM;IAOI,8BAAA;EL8wGV;EKrxGM;IAOI,gCAAA;ELixGV;EKxxGM;IAOI,8BAAA;ELoxGV;EK3xGM;IAOI,8BAAA;ELuxGV;EK9xGM;IAOI,0BAAA;EL0xGV;EKjyGM;IAOI,gCAAA;EL6xGV;EKpyGM;IAOI,+BAAA;ELgyGV;EKvyGM;IAOI,6BAAA;ELmyGV;EK1yGM;IAOI,+BAAA;ELsyGV;EK7yGM;IAOI,6BAAA;ELyyGV;EKhzGM;IAOI,6BAAA;EL4yGV;EKnzGM;IAOI,qBAAA;EL+yGV;EKtzGM;IAOI,2BAAA;ELkzGV;EKzzGM;IAOI,0BAAA;ELqzGV;EK5zGM;IAOI,wBAAA;ELwzGV;EK/zGM;IAOI,0BAAA;EL2zGV;EKl0GM;IAOI,wBAAA;EL8zGV;EKr0GM;IAOI,0BAAA;IAAA,2BAAA;ELk0GV;EKz0GM;IAOI,gCAAA;IAAA,iCAAA;ELs0GV;EK70GM;IAOI,+BAAA;IAAA,gCAAA;EL00GV;EKj1GM;IAOI,6BAAA;IAAA,8BAAA;EL80GV;EKr1GM;IAOI,+BAAA;IAAA,gCAAA;ELk1GV;EKz1GM;IAOI,6BAAA;IAAA,8BAAA;ELs1GV;EK71GM;IAOI,yBAAA;IAAA,4BAAA;EL01GV;EKj2GM;IAOI,+BAAA;IAAA,kCAAA;EL81GV;EKr2GM;IAOI,8BAAA;IAAA,iCAAA;ELk2GV;EKz2GM;IAOI,4BAAA;IAAA,+BAAA;ELs2GV;EK72GM;IAOI,8BAAA;IAAA,iCAAA;EL02GV;EKj3GM;IAOI,4BAAA;IAAA,+BAAA;EL82GV;EKr3GM;IAOI,yBAAA;ELi3GV;EKx3GM;IAOI,+BAAA;ELo3GV;EK33GM;IAOI,8BAAA;ELu3GV;EK93GM;IAOI,4BAAA;EL03GV;EKj4GM;IAOI,8BAAA;EL63GV;EKp4GM;IAOI,4BAAA;ELg4GV;EKv4GM;IAOI,0BAAA;ELm4GV;EK14GM;IAOI,gCAAA;ELs4GV;EK74GM;IAOI,+BAAA;ELy4GV;EKh5GM;IAOI,6BAAA;EL44GV;EKn5GM;IAOI,+BAAA;EL+4GV;EKt5GM;IAOI,6BAAA;ELk5GV;EKz5GM;IAOI,4BAAA;ELq5GV;EK55GM;IAOI,kCAAA;ELw5GV;EK/5GM;IAOI,iCAAA;EL25GV;EKl6GM;IAOI,+BAAA;EL85GV;EKr6GM;IAOI,iCAAA;ELi6GV;EKx6GM;IAOI,+BAAA;ELo6GV;EK36GM;IAOI,2BAAA;ELu6GV;EK96GM;IAOI,iCAAA;EL06GV;EKj7GM;IAOI,gCAAA;EL66GV;EKp7GM;IAOI,8BAAA;ELg7GV;EKv7GM;IAOI,gCAAA;ELm7GV;EK17GM;IAOI,8BAAA;ELs7GV;AACF;ACj8GI;EIGI;IAOI,0BAAA;EL27GV;EKl8GM;IAOI,gCAAA;EL87GV;EKr8GM;IAOI,yBAAA;ELi8GV;EKx8GM;IAOI,wBAAA;ELo8GV;EK38GM;IAOI,+BAAA;ELu8GV;EK98GM;IAOI,yBAAA;EL08GV;EKj9GM;IAOI,6BAAA;EL68GV;EKp9GM;IAOI,8BAAA;ELg9GV;EKv9GM;IAOI,wBAAA;ELm9GV;EK19GM;IAOI,+BAAA;ELs9GV;EK79GM;IAOI,wBAAA;ELy9GV;EKh+GM;IAOI,yBAAA;EL49GV;EKn+GM;IAOI,8BAAA;EL+9GV;EKt+GM;IAOI,iCAAA;ELk+GV;EKz+GM;IAOI,sCAAA;ELq+GV;EK5+GM;IAOI,yCAAA;ELw+GV;EK/+GM;IAOI,uBAAA;EL2+GV;EKl/GM;IAOI,uBAAA;EL8+GV;EKr/GM;IAOI,yBAAA;ELi/GV;EKx/GM;IAOI,yBAAA;ELo/GV;EK3/GM;IAOI,0BAAA;ELu/GV;EK9/GM;IAOI,4BAAA;EL0/GV;EKjgHM;IAOI,kCAAA;EL6/GV;EKpgHM;IAOI,sCAAA;ELggHV;EKvgHM;IAOI,oCAAA;ELmgHV;EK1gHM;IAOI,kCAAA;ELsgHV;EK7gHM;IAOI,yCAAA;ELygHV;EKhhHM;IAOI,wCAAA;EL4gHV;EKnhHM;IAOI,wCAAA;EL+gHV;EKthHM;IAOI,kCAAA;ELkhHV;EKzhHM;IAOI,gCAAA;ELqhHV;EK5hHM;IAOI,8BAAA;ELwhHV;EK/hHM;IAOI,gCAAA;EL2hHV;EKliHM;IAOI,+BAAA;EL8hHV;EKriHM;IAOI,oCAAA;ELiiHV;EKxiHM;IAOI,kCAAA;ELoiHV;EK3iHM;IAOI,gCAAA;ELuiHV;EK9iHM;IAOI,uCAAA;EL0iHV;EKjjHM;IAOI,sCAAA;EL6iHV;EKpjHM;IAOI,iCAAA;ELgjHV;EKvjHM;IAOI,2BAAA;ELmjHV;EK1jHM;IAOI,iCAAA;ELsjHV;EK7jHM;IAOI,+BAAA;ELyjHV;EKhkHM;IAOI,6BAAA;EL4jHV;EKnkHM;IAOI,+BAAA;EL+jHV;EKtkHM;IAOI,8BAAA;ELkkHV;EKzkHM;IAOI,oBAAA;ELqkHV;EK5kHM;IAOI,mBAAA;ELwkHV;EK/kHM;IAOI,mBAAA;EL2kHV;EKllHM;IAOI,mBAAA;EL8kHV;EKrlHM;IAOI,mBAAA;ELilHV;EKxlHM;IAOI,mBAAA;ELolHV;EK3lHM;IAOI,mBAAA;ELulHV;EK9lHM;IAOI,mBAAA;EL0lHV;EKjmHM;IAOI,oBAAA;EL6lHV;EKpmHM;IAOI,0BAAA;ELgmHV;EKvmHM;IAOI,yBAAA;ELmmHV;EK1mHM;IAOI,uBAAA;ELsmHV;EK7mHM;IAOI,yBAAA;ELymHV;EKhnHM;IAOI,uBAAA;EL4mHV;EKnnHM;IAOI,uBAAA;EL+mHV;EKtnHM;IAOI,yBAAA;IAAA,0BAAA;ELmnHV;EK1nHM;IAOI,+BAAA;IAAA,gCAAA;ELunHV;EK9nHM;IAOI,8BAAA;IAAA,+BAAA;EL2nHV;EKloHM;IAOI,4BAAA;IAAA,6BAAA;EL+nHV;EKtoHM;IAOI,8BAAA;IAAA,+BAAA;ELmoHV;EK1oHM;IAOI,4BAAA;IAAA,6BAAA;ELuoHV;EK9oHM;IAOI,4BAAA;IAAA,6BAAA;EL2oHV;EKlpHM;IAOI,wBAAA;IAAA,2BAAA;EL+oHV;EKtpHM;IAOI,8BAAA;IAAA,iCAAA;ELmpHV;EK1pHM;IAOI,6BAAA;IAAA,gCAAA;ELupHV;EK9pHM;IAOI,2BAAA;IAAA,8BAAA;EL2pHV;EKlqHM;IAOI,6BAAA;IAAA,gCAAA;EL+pHV;EKtqHM;IAOI,2BAAA;IAAA,8BAAA;ELmqHV;EK1qHM;IAOI,2BAAA;IAAA,8BAAA;ELuqHV;EK9qHM;IAOI,wBAAA;EL0qHV;EKjrHM;IAOI,8BAAA;EL6qHV;EKprHM;IAOI,6BAAA;ELgrHV;EKvrHM;IAOI,2BAAA;ELmrHV;EK1rHM;IAOI,6BAAA;ELsrHV;EK7rHM;IAOI,2BAAA;ELyrHV;EKhsHM;IAOI,2BAAA;EL4rHV;EKnsHM;IAOI,yBAAA;EL+rHV;EKtsHM;IAOI,+BAAA;ELksHV;EKzsHM;IAOI,8BAAA;ELqsHV;EK5sHM;IAOI,4BAAA;ELwsHV;EK/sHM;IAOI,8BAAA;EL2sHV;EKltHM;IAOI,4BAAA;EL8sHV;EKrtHM;IAOI,4BAAA;ELitHV;EKxtHM;IAOI,2BAAA;ELotHV;EK3tHM;IAOI,iCAAA;ELutHV;EK9tHM;IAOI,gCAAA;EL0tHV;EKjuHM;IAOI,8BAAA;EL6tHV;EKpuHM;IAOI,gCAAA;ELguHV;EKvuHM;IAOI,8BAAA;ELmuHV;EK1uHM;IAOI,8BAAA;ELsuHV;EK7uHM;IAOI,0BAAA;ELyuHV;EKhvHM;IAOI,gCAAA;EL4uHV;EKnvHM;IAOI,+BAAA;EL+uHV;EKtvHM;IAOI,6BAAA;ELkvHV;EKzvHM;IAOI,+BAAA;ELqvHV;EK5vHM;IAOI,6BAAA;ELwvHV;EK/vHM;IAOI,6BAAA;EL2vHV;EKlwHM;IAOI,qBAAA;EL8vHV;EKrwHM;IAOI,2BAAA;ELiwHV;EKxwHM;IAOI,0BAAA;ELowHV;EK3wHM;IAOI,wBAAA;ELuwHV;EK9wHM;IAOI,0BAAA;EL0wHV;EKjxHM;IAOI,wBAAA;EL6wHV;EKpxHM;IAOI,0BAAA;IAAA,2BAAA;ELixHV;EKxxHM;IAOI,gCAAA;IAAA,iCAAA;ELqxHV;EK5xHM;IAOI,+BAAA;IAAA,gCAAA;ELyxHV;EKhyHM;IAOI,6BAAA;IAAA,8BAAA;EL6xHV;EKpyHM;IAOI,+BAAA;IAAA,gCAAA;ELiyHV;EKxyHM;IAOI,6BAAA;IAAA,8BAAA;ELqyHV;EK5yHM;IAOI,yBAAA;IAAA,4BAAA;ELyyHV;EKhzHM;IAOI,+BAAA;IAAA,kCAAA;EL6yHV;EKpzHM;IAOI,8BAAA;IAAA,iCAAA;ELizHV;EKxzHM;IAOI,4BAAA;IAAA,+BAAA;ELqzHV;EK5zHM;IAOI,8BAAA;IAAA,iCAAA;ELyzHV;EKh0HM;IAOI,4BAAA;IAAA,+BAAA;EL6zHV;EKp0HM;IAOI,yBAAA;ELg0HV;EKv0HM;IAOI,+BAAA;ELm0HV;EK10HM;IAOI,8BAAA;ELs0HV;EK70HM;IAOI,4BAAA;ELy0HV;EKh1HM;IAOI,8BAAA;EL40HV;EKn1HM;IAOI,4BAAA;EL+0HV;EKt1HM;IAOI,0BAAA;ELk1HV;EKz1HM;IAOI,gCAAA;ELq1HV;EK51HM;IAOI,+BAAA;ELw1HV;EK/1HM;IAOI,6BAAA;EL21HV;EKl2HM;IAOI,+BAAA;EL81HV;EKr2HM;IAOI,6BAAA;ELi2HV;EKx2HM;IAOI,4BAAA;ELo2HV;EK32HM;IAOI,kCAAA;ELu2HV;EK92HM;IAOI,iCAAA;EL02HV;EKj3HM;IAOI,+BAAA;EL62HV;EKp3HM;IAOI,iCAAA;ELg3HV;EKv3HM;IAOI,+BAAA;ELm3HV;EK13HM;IAOI,2BAAA;ELs3HV;EK73HM;IAOI,iCAAA;ELy3HV;EKh4HM;IAOI,gCAAA;EL43HV;EKn4HM;IAOI,8BAAA;EL+3HV;EKt4HM;IAOI,gCAAA;ELk4HV;EKz4HM;IAOI,8BAAA;ELq4HV;AACF;AMz6HA;ED4BQ;IAOI,0BAAA;EL04HV;EKj5HM;IAOI,gCAAA;EL64HV;EKp5HM;IAOI,yBAAA;ELg5HV;EKv5HM;IAOI,wBAAA;ELm5HV;EK15HM;IAOI,+BAAA;ELs5HV;EK75HM;IAOI,yBAAA;ELy5HV;EKh6HM;IAOI,6BAAA;EL45HV;EKn6HM;IAOI,8BAAA;EL+5HV;EKt6HM;IAOI,wBAAA;ELk6HV;EKz6HM;IAOI,+BAAA;ELq6HV;EK56HM;IAOI,wBAAA;ELw6HV;AACF","file":"bootstrap-grid.rtl.css","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-right: auto;\n margin-left: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-left: 8.33333333%;\n}\n\n.offset-2 {\n margin-left: 16.66666667%;\n}\n\n.offset-3 {\n margin-left: 25%;\n}\n\n.offset-4 {\n margin-left: 33.33333333%;\n}\n\n.offset-5 {\n margin-left: 41.66666667%;\n}\n\n.offset-6 {\n margin-left: 50%;\n}\n\n.offset-7 {\n margin-left: 58.33333333%;\n}\n\n.offset-8 {\n margin-left: 66.66666667%;\n}\n\n.offset-9 {\n margin-left: 75%;\n}\n\n.offset-10 {\n margin-left: 83.33333333%;\n}\n\n.offset-11 {\n margin-left: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-left: 0;\n }\n .offset-sm-1 {\n margin-left: 8.33333333%;\n }\n .offset-sm-2 {\n margin-left: 16.66666667%;\n }\n .offset-sm-3 {\n margin-left: 25%;\n }\n .offset-sm-4 {\n margin-left: 33.33333333%;\n }\n .offset-sm-5 {\n margin-left: 41.66666667%;\n }\n .offset-sm-6 {\n margin-left: 50%;\n }\n .offset-sm-7 {\n margin-left: 58.33333333%;\n }\n .offset-sm-8 {\n margin-left: 66.66666667%;\n }\n .offset-sm-9 {\n margin-left: 75%;\n }\n .offset-sm-10 {\n margin-left: 83.33333333%;\n }\n .offset-sm-11 {\n margin-left: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-left: 0;\n }\n .offset-md-1 {\n margin-left: 8.33333333%;\n }\n .offset-md-2 {\n margin-left: 16.66666667%;\n }\n .offset-md-3 {\n margin-left: 25%;\n }\n .offset-md-4 {\n margin-left: 33.33333333%;\n }\n .offset-md-5 {\n margin-left: 41.66666667%;\n }\n .offset-md-6 {\n margin-left: 50%;\n }\n .offset-md-7 {\n margin-left: 58.33333333%;\n }\n .offset-md-8 {\n margin-left: 66.66666667%;\n }\n .offset-md-9 {\n margin-left: 75%;\n }\n .offset-md-10 {\n margin-left: 83.33333333%;\n }\n .offset-md-11 {\n margin-left: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-left: 0;\n }\n .offset-lg-1 {\n margin-left: 8.33333333%;\n }\n .offset-lg-2 {\n margin-left: 16.66666667%;\n }\n .offset-lg-3 {\n margin-left: 25%;\n }\n .offset-lg-4 {\n margin-left: 33.33333333%;\n }\n .offset-lg-5 {\n margin-left: 41.66666667%;\n }\n .offset-lg-6 {\n margin-left: 50%;\n }\n .offset-lg-7 {\n margin-left: 58.33333333%;\n }\n .offset-lg-8 {\n margin-left: 66.66666667%;\n }\n .offset-lg-9 {\n margin-left: 75%;\n }\n .offset-lg-10 {\n margin-left: 83.33333333%;\n }\n .offset-lg-11 {\n margin-left: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-left: 0;\n }\n .offset-xl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xl-3 {\n margin-left: 25%;\n }\n .offset-xl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xl-6 {\n margin-left: 50%;\n }\n .offset-xl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xl-9 {\n margin-left: 75%;\n }\n .offset-xl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xl-11 {\n margin-left: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-left: 0;\n }\n .offset-xxl-1 {\n margin-left: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-left: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-left: 25%;\n }\n .offset-xxl-4 {\n margin-left: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-left: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-left: 50%;\n }\n .offset-xxl-7 {\n margin-left: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-left: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-left: 75%;\n }\n .offset-xxl-10 {\n margin-left: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-left: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n}\n\n.mx-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n}\n\n.mx-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n}\n\n.mx-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n}\n\n.mx-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n}\n\n.mx-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n}\n\n.mx-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-right: 0 !important;\n}\n\n.me-1 {\n margin-right: 0.25rem !important;\n}\n\n.me-2 {\n margin-right: 0.5rem !important;\n}\n\n.me-3 {\n margin-right: 1rem !important;\n}\n\n.me-4 {\n margin-right: 1.5rem !important;\n}\n\n.me-5 {\n margin-right: 3rem !important;\n}\n\n.me-auto {\n margin-right: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-left: 0 !important;\n}\n\n.ms-1 {\n margin-left: 0.25rem !important;\n}\n\n.ms-2 {\n margin-left: 0.5rem !important;\n}\n\n.ms-3 {\n margin-left: 1rem !important;\n}\n\n.ms-4 {\n margin-left: 1.5rem !important;\n}\n\n.ms-5 {\n margin-left: 3rem !important;\n}\n\n.ms-auto {\n margin-left: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n}\n\n.px-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n}\n\n.px-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n}\n\n.px-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n}\n\n.px-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n}\n\n.px-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-right: 0 !important;\n}\n\n.pe-1 {\n padding-right: 0.25rem !important;\n}\n\n.pe-2 {\n padding-right: 0.5rem !important;\n}\n\n.pe-3 {\n padding-right: 1rem !important;\n}\n\n.pe-4 {\n padding-right: 1.5rem !important;\n}\n\n.pe-5 {\n padding-right: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-left: 0 !important;\n}\n\n.ps-1 {\n padding-left: 0.25rem !important;\n}\n\n.ps-2 {\n padding-left: 0.5rem !important;\n}\n\n.ps-3 {\n padding-left: 1rem !important;\n}\n\n.ps-4 {\n padding-left: 1.5rem !important;\n}\n\n.ps-5 {\n padding-left: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-sm-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-sm-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-sm-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-right: 0 !important;\n }\n .me-sm-1 {\n margin-right: 0.25rem !important;\n }\n .me-sm-2 {\n margin-right: 0.5rem !important;\n }\n .me-sm-3 {\n margin-right: 1rem !important;\n }\n .me-sm-4 {\n margin-right: 1.5rem !important;\n }\n .me-sm-5 {\n margin-right: 3rem !important;\n }\n .me-sm-auto {\n margin-right: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-left: 0 !important;\n }\n .ms-sm-1 {\n margin-left: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-left: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-left: 1rem !important;\n }\n .ms-sm-4 {\n margin-left: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-left: 3rem !important;\n }\n .ms-sm-auto {\n margin-left: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-sm-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-sm-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-sm-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-sm-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-sm-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-right: 0 !important;\n }\n .pe-sm-1 {\n padding-right: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-right: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-right: 1rem !important;\n }\n .pe-sm-4 {\n padding-right: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-right: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-left: 0 !important;\n }\n .ps-sm-1 {\n padding-left: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-left: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-left: 1rem !important;\n }\n .ps-sm-4 {\n padding-left: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-md-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-md-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-md-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-md-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-md-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-md-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-right: 0 !important;\n }\n .me-md-1 {\n margin-right: 0.25rem !important;\n }\n .me-md-2 {\n margin-right: 0.5rem !important;\n }\n .me-md-3 {\n margin-right: 1rem !important;\n }\n .me-md-4 {\n margin-right: 1.5rem !important;\n }\n .me-md-5 {\n margin-right: 3rem !important;\n }\n .me-md-auto {\n margin-right: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-left: 0 !important;\n }\n .ms-md-1 {\n margin-left: 0.25rem !important;\n }\n .ms-md-2 {\n margin-left: 0.5rem !important;\n }\n .ms-md-3 {\n margin-left: 1rem !important;\n }\n .ms-md-4 {\n margin-left: 1.5rem !important;\n }\n .ms-md-5 {\n margin-left: 3rem !important;\n }\n .ms-md-auto {\n margin-left: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-md-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-md-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-md-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-md-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-md-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-right: 0 !important;\n }\n .pe-md-1 {\n padding-right: 0.25rem !important;\n }\n .pe-md-2 {\n padding-right: 0.5rem !important;\n }\n .pe-md-3 {\n padding-right: 1rem !important;\n }\n .pe-md-4 {\n padding-right: 1.5rem !important;\n }\n .pe-md-5 {\n padding-right: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-left: 0 !important;\n }\n .ps-md-1 {\n padding-left: 0.25rem !important;\n }\n .ps-md-2 {\n padding-left: 0.5rem !important;\n }\n .ps-md-3 {\n padding-left: 1rem !important;\n }\n .ps-md-4 {\n padding-left: 1.5rem !important;\n }\n .ps-md-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-lg-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-lg-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-lg-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-right: 0 !important;\n }\n .me-lg-1 {\n margin-right: 0.25rem !important;\n }\n .me-lg-2 {\n margin-right: 0.5rem !important;\n }\n .me-lg-3 {\n margin-right: 1rem !important;\n }\n .me-lg-4 {\n margin-right: 1.5rem !important;\n }\n .me-lg-5 {\n margin-right: 3rem !important;\n }\n .me-lg-auto {\n margin-right: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-left: 0 !important;\n }\n .ms-lg-1 {\n margin-left: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-left: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-left: 1rem !important;\n }\n .ms-lg-4 {\n margin-left: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-left: 3rem !important;\n }\n .ms-lg-auto {\n margin-left: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-lg-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-lg-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-lg-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-lg-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-lg-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-right: 0 !important;\n }\n .pe-lg-1 {\n padding-right: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-right: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-right: 1rem !important;\n }\n .pe-lg-4 {\n padding-right: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-right: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-left: 0 !important;\n }\n .ps-lg-1 {\n padding-left: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-left: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-left: 1rem !important;\n }\n .ps-lg-4 {\n padding-left: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-right: 0 !important;\n }\n .me-xl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xl-3 {\n margin-right: 1rem !important;\n }\n .me-xl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xl-5 {\n margin-right: 3rem !important;\n }\n .me-xl-auto {\n margin-right: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-left: 0 !important;\n }\n .ms-xl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-left: 1rem !important;\n }\n .ms-xl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-left: 3rem !important;\n }\n .ms-xl-auto {\n margin-left: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-right: 0 !important;\n }\n .pe-xl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-right: 1rem !important;\n }\n .pe-xl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-right: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-left: 0 !important;\n }\n .ps-xl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-left: 1rem !important;\n }\n .ps-xl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-left: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-right: 0 !important;\n margin-left: 0 !important;\n }\n .mx-xxl-1 {\n margin-right: 0.25rem !important;\n margin-left: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-right: 0.5rem !important;\n margin-left: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-right: 1rem !important;\n margin-left: 1rem !important;\n }\n .mx-xxl-4 {\n margin-right: 1.5rem !important;\n margin-left: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-right: 3rem !important;\n margin-left: 3rem !important;\n }\n .mx-xxl-auto {\n margin-right: auto !important;\n margin-left: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-right: 0 !important;\n }\n .me-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-right: 1rem !important;\n }\n .me-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-right: 3rem !important;\n }\n .me-xxl-auto {\n margin-right: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-left: 0 !important;\n }\n .ms-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-left: 1rem !important;\n }\n .ms-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-left: 3rem !important;\n }\n .ms-xxl-auto {\n margin-left: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-right: 0 !important;\n padding-left: 0 !important;\n }\n .px-xxl-1 {\n padding-right: 0.25rem !important;\n padding-left: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-right: 0.5rem !important;\n padding-left: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-right: 1rem !important;\n padding-left: 1rem !important;\n }\n .px-xxl-4 {\n padding-right: 1.5rem !important;\n padding-left: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-right: 3rem !important;\n padding-left: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-right: 0 !important;\n }\n .pe-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-right: 1rem !important;\n }\n .pe-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-right: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-left: 0 !important;\n }\n .ps-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-left: 1rem !important;\n }\n .ps-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-left: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n\n/*# sourceMappingURL=bootstrap-grid.css.map */\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Variables\n//\n// Variables should follow the `$component-state-property-size` formula for\n// consistent naming. Ex: $nav-link-disabled-color and $modal-content-box-shadow-xs.\n\n// Color system\n\n// scss-docs-start gray-color-variables\n$white: #fff !default;\n$gray-100: #f8f9fa !default;\n$gray-200: #e9ecef !default;\n$gray-300: #dee2e6 !default;\n$gray-400: #ced4da !default;\n$gray-500: #adb5bd !default;\n$gray-600: #6c757d !default;\n$gray-700: #495057 !default;\n$gray-800: #343a40 !default;\n$gray-900: #212529 !default;\n$black: #000 !default;\n// scss-docs-end gray-color-variables\n\n// fusv-disable\n// scss-docs-start gray-colors-map\n$grays: (\n \"100\": $gray-100,\n \"200\": $gray-200,\n \"300\": $gray-300,\n \"400\": $gray-400,\n \"500\": $gray-500,\n \"600\": $gray-600,\n \"700\": $gray-700,\n \"800\": $gray-800,\n \"900\": $gray-900\n) !default;\n// scss-docs-end gray-colors-map\n// fusv-enable\n\n// scss-docs-start color-variables\n$blue: #0d6efd !default;\n$indigo: #6610f2 !default;\n$purple: #6f42c1 !default;\n$pink: #d63384 !default;\n$red: #dc3545 !default;\n$orange: #fd7e14 !default;\n$yellow: #ffc107 !default;\n$green: #198754 !default;\n$teal: #20c997 !default;\n$cyan: #0dcaf0 !default;\n// scss-docs-end color-variables\n\n// scss-docs-start colors-map\n$colors: (\n \"blue\": $blue,\n \"indigo\": $indigo,\n \"purple\": $purple,\n \"pink\": $pink,\n \"red\": $red,\n \"orange\": $orange,\n \"yellow\": $yellow,\n \"green\": $green,\n \"teal\": $teal,\n \"cyan\": $cyan,\n \"black\": $black,\n \"white\": $white,\n \"gray\": $gray-600,\n \"gray-dark\": $gray-800\n) !default;\n// scss-docs-end colors-map\n\n// The contrast ratio to reach against white, to determine if color changes from \"light\" to \"dark\". Acceptable values for WCAG 2.0 are 3, 4.5 and 7.\n// See https://www.w3.org/TR/WCAG20/#visual-audio-contrast-contrast\n$min-contrast-ratio: 4.5 !default;\n\n// Customize the light and dark text colors for use in our color contrast function.\n$color-contrast-dark: $black !default;\n$color-contrast-light: $white !default;\n\n// fusv-disable\n$blue-100: tint-color($blue, 80%) !default;\n$blue-200: tint-color($blue, 60%) !default;\n$blue-300: tint-color($blue, 40%) !default;\n$blue-400: tint-color($blue, 20%) !default;\n$blue-500: $blue !default;\n$blue-600: shade-color($blue, 20%) !default;\n$blue-700: shade-color($blue, 40%) !default;\n$blue-800: shade-color($blue, 60%) !default;\n$blue-900: shade-color($blue, 80%) !default;\n\n$indigo-100: tint-color($indigo, 80%) !default;\n$indigo-200: tint-color($indigo, 60%) !default;\n$indigo-300: tint-color($indigo, 40%) !default;\n$indigo-400: tint-color($indigo, 20%) !default;\n$indigo-500: $indigo !default;\n$indigo-600: shade-color($indigo, 20%) !default;\n$indigo-700: shade-color($indigo, 40%) !default;\n$indigo-800: shade-color($indigo, 60%) !default;\n$indigo-900: shade-color($indigo, 80%) !default;\n\n$purple-100: tint-color($purple, 80%) !default;\n$purple-200: tint-color($purple, 60%) !default;\n$purple-300: tint-color($purple, 40%) !default;\n$purple-400: tint-color($purple, 20%) !default;\n$purple-500: $purple !default;\n$purple-600: shade-color($purple, 20%) !default;\n$purple-700: shade-color($purple, 40%) !default;\n$purple-800: shade-color($purple, 60%) !default;\n$purple-900: shade-color($purple, 80%) !default;\n\n$pink-100: tint-color($pink, 80%) !default;\n$pink-200: tint-color($pink, 60%) !default;\n$pink-300: tint-color($pink, 40%) !default;\n$pink-400: tint-color($pink, 20%) !default;\n$pink-500: $pink !default;\n$pink-600: shade-color($pink, 20%) !default;\n$pink-700: shade-color($pink, 40%) !default;\n$pink-800: shade-color($pink, 60%) !default;\n$pink-900: shade-color($pink, 80%) !default;\n\n$red-100: tint-color($red, 80%) !default;\n$red-200: tint-color($red, 60%) !default;\n$red-300: tint-color($red, 40%) !default;\n$red-400: tint-color($red, 20%) !default;\n$red-500: $red !default;\n$red-600: shade-color($red, 20%) !default;\n$red-700: shade-color($red, 40%) !default;\n$red-800: shade-color($red, 60%) !default;\n$red-900: shade-color($red, 80%) !default;\n\n$orange-100: tint-color($orange, 80%) !default;\n$orange-200: tint-color($orange, 60%) !default;\n$orange-300: tint-color($orange, 40%) !default;\n$orange-400: tint-color($orange, 20%) !default;\n$orange-500: $orange !default;\n$orange-600: shade-color($orange, 20%) !default;\n$orange-700: shade-color($orange, 40%) !default;\n$orange-800: shade-color($orange, 60%) !default;\n$orange-900: shade-color($orange, 80%) !default;\n\n$yellow-100: tint-color($yellow, 80%) !default;\n$yellow-200: tint-color($yellow, 60%) !default;\n$yellow-300: tint-color($yellow, 40%) !default;\n$yellow-400: tint-color($yellow, 20%) !default;\n$yellow-500: $yellow !default;\n$yellow-600: shade-color($yellow, 20%) !default;\n$yellow-700: shade-color($yellow, 40%) !default;\n$yellow-800: shade-color($yellow, 60%) !default;\n$yellow-900: shade-color($yellow, 80%) !default;\n\n$green-100: tint-color($green, 80%) !default;\n$green-200: tint-color($green, 60%) !default;\n$green-300: tint-color($green, 40%) !default;\n$green-400: tint-color($green, 20%) !default;\n$green-500: $green !default;\n$green-600: shade-color($green, 20%) !default;\n$green-700: shade-color($green, 40%) !default;\n$green-800: shade-color($green, 60%) !default;\n$green-900: shade-color($green, 80%) !default;\n\n$teal-100: tint-color($teal, 80%) !default;\n$teal-200: tint-color($teal, 60%) !default;\n$teal-300: tint-color($teal, 40%) !default;\n$teal-400: tint-color($teal, 20%) !default;\n$teal-500: $teal !default;\n$teal-600: shade-color($teal, 20%) !default;\n$teal-700: shade-color($teal, 40%) !default;\n$teal-800: shade-color($teal, 60%) !default;\n$teal-900: shade-color($teal, 80%) !default;\n\n$cyan-100: tint-color($cyan, 80%) !default;\n$cyan-200: tint-color($cyan, 60%) !default;\n$cyan-300: tint-color($cyan, 40%) !default;\n$cyan-400: tint-color($cyan, 20%) !default;\n$cyan-500: $cyan !default;\n$cyan-600: shade-color($cyan, 20%) !default;\n$cyan-700: shade-color($cyan, 40%) !default;\n$cyan-800: shade-color($cyan, 60%) !default;\n$cyan-900: shade-color($cyan, 80%) !default;\n\n$blues: (\n \"blue-100\": $blue-100,\n \"blue-200\": $blue-200,\n \"blue-300\": $blue-300,\n \"blue-400\": $blue-400,\n \"blue-500\": $blue-500,\n \"blue-600\": $blue-600,\n \"blue-700\": $blue-700,\n \"blue-800\": $blue-800,\n \"blue-900\": $blue-900\n) !default;\n\n$indigos: (\n \"indigo-100\": $indigo-100,\n \"indigo-200\": $indigo-200,\n \"indigo-300\": $indigo-300,\n \"indigo-400\": $indigo-400,\n \"indigo-500\": $indigo-500,\n \"indigo-600\": $indigo-600,\n \"indigo-700\": $indigo-700,\n \"indigo-800\": $indigo-800,\n \"indigo-900\": $indigo-900\n) !default;\n\n$purples: (\n \"purple-100\": $purple-100,\n \"purple-200\": $purple-200,\n \"purple-300\": $purple-300,\n \"purple-400\": $purple-400,\n \"purple-500\": $purple-500,\n \"purple-600\": $purple-600,\n \"purple-700\": $purple-700,\n \"purple-800\": $purple-800,\n \"purple-900\": $purple-900\n) !default;\n\n$pinks: (\n \"pink-100\": $pink-100,\n \"pink-200\": $pink-200,\n \"pink-300\": $pink-300,\n \"pink-400\": $pink-400,\n \"pink-500\": $pink-500,\n \"pink-600\": $pink-600,\n \"pink-700\": $pink-700,\n \"pink-800\": $pink-800,\n \"pink-900\": $pink-900\n) !default;\n\n$reds: (\n \"red-100\": $red-100,\n \"red-200\": $red-200,\n \"red-300\": $red-300,\n \"red-400\": $red-400,\n \"red-500\": $red-500,\n \"red-600\": $red-600,\n \"red-700\": $red-700,\n \"red-800\": $red-800,\n \"red-900\": $red-900\n) !default;\n\n$oranges: (\n \"orange-100\": $orange-100,\n \"orange-200\": $orange-200,\n \"orange-300\": $orange-300,\n \"orange-400\": $orange-400,\n \"orange-500\": $orange-500,\n \"orange-600\": $orange-600,\n \"orange-700\": $orange-700,\n \"orange-800\": $orange-800,\n \"orange-900\": $orange-900\n) !default;\n\n$yellows: (\n \"yellow-100\": $yellow-100,\n \"yellow-200\": $yellow-200,\n \"yellow-300\": $yellow-300,\n \"yellow-400\": $yellow-400,\n \"yellow-500\": $yellow-500,\n \"yellow-600\": $yellow-600,\n \"yellow-700\": $yellow-700,\n \"yellow-800\": $yellow-800,\n \"yellow-900\": $yellow-900\n) !default;\n\n$greens: (\n \"green-100\": $green-100,\n \"green-200\": $green-200,\n \"green-300\": $green-300,\n \"green-400\": $green-400,\n \"green-500\": $green-500,\n \"green-600\": $green-600,\n \"green-700\": $green-700,\n \"green-800\": $green-800,\n \"green-900\": $green-900\n) !default;\n\n$teals: (\n \"teal-100\": $teal-100,\n \"teal-200\": $teal-200,\n \"teal-300\": $teal-300,\n \"teal-400\": $teal-400,\n \"teal-500\": $teal-500,\n \"teal-600\": $teal-600,\n \"teal-700\": $teal-700,\n \"teal-800\": $teal-800,\n \"teal-900\": $teal-900\n) !default;\n\n$cyans: (\n \"cyan-100\": $cyan-100,\n \"cyan-200\": $cyan-200,\n \"cyan-300\": $cyan-300,\n \"cyan-400\": $cyan-400,\n \"cyan-500\": $cyan-500,\n \"cyan-600\": $cyan-600,\n \"cyan-700\": $cyan-700,\n \"cyan-800\": $cyan-800,\n \"cyan-900\": $cyan-900\n) !default;\n// fusv-enable\n\n// scss-docs-start theme-color-variables\n$primary: $blue !default;\n$secondary: $gray-600 !default;\n$success: $green !default;\n$info: $cyan !default;\n$warning: $yellow !default;\n$danger: $red !default;\n$light: $gray-100 !default;\n$dark: $gray-900 !default;\n// scss-docs-end theme-color-variables\n\n// scss-docs-start theme-colors-map\n$theme-colors: (\n \"primary\": $primary,\n \"secondary\": $secondary,\n \"success\": $success,\n \"info\": $info,\n \"warning\": $warning,\n \"danger\": $danger,\n \"light\": $light,\n \"dark\": $dark\n) !default;\n// scss-docs-end theme-colors-map\n\n// scss-docs-start theme-text-variables\n$primary-text-emphasis: shade-color($primary, 60%) !default;\n$secondary-text-emphasis: shade-color($secondary, 60%) !default;\n$success-text-emphasis: shade-color($success, 60%) !default;\n$info-text-emphasis: shade-color($info, 60%) !default;\n$warning-text-emphasis: shade-color($warning, 60%) !default;\n$danger-text-emphasis: shade-color($danger, 60%) !default;\n$light-text-emphasis: $gray-700 !default;\n$dark-text-emphasis: $gray-700 !default;\n// scss-docs-end theme-text-variables\n\n// scss-docs-start theme-bg-subtle-variables\n$primary-bg-subtle: tint-color($primary, 80%) !default;\n$secondary-bg-subtle: tint-color($secondary, 80%) !default;\n$success-bg-subtle: tint-color($success, 80%) !default;\n$info-bg-subtle: tint-color($info, 80%) !default;\n$warning-bg-subtle: tint-color($warning, 80%) !default;\n$danger-bg-subtle: tint-color($danger, 80%) !default;\n$light-bg-subtle: mix($gray-100, $white) !default;\n$dark-bg-subtle: $gray-400 !default;\n// scss-docs-end theme-bg-subtle-variables\n\n// scss-docs-start theme-border-subtle-variables\n$primary-border-subtle: tint-color($primary, 60%) !default;\n$secondary-border-subtle: tint-color($secondary, 60%) !default;\n$success-border-subtle: tint-color($success, 60%) !default;\n$info-border-subtle: tint-color($info, 60%) !default;\n$warning-border-subtle: tint-color($warning, 60%) !default;\n$danger-border-subtle: tint-color($danger, 60%) !default;\n$light-border-subtle: $gray-200 !default;\n$dark-border-subtle: $gray-500 !default;\n// scss-docs-end theme-border-subtle-variables\n\n// Characters which are escaped by the escape-svg function\n$escaped-characters: (\n (\"<\", \"%3c\"),\n (\">\", \"%3e\"),\n (\"#\", \"%23\"),\n (\"(\", \"%28\"),\n (\")\", \"%29\"),\n) !default;\n\n// Options\n//\n// Quickly modify global styling by enabling or disabling optional features.\n\n$enable-caret: true !default;\n$enable-rounded: true !default;\n$enable-shadows: false !default;\n$enable-gradients: false !default;\n$enable-transitions: true !default;\n$enable-reduced-motion: true !default;\n$enable-smooth-scroll: true !default;\n$enable-grid-classes: true !default;\n$enable-container-classes: true !default;\n$enable-cssgrid: false !default;\n$enable-button-pointers: true !default;\n$enable-rfs: true !default;\n$enable-validation-icons: true !default;\n$enable-negative-margins: false !default;\n$enable-deprecation-messages: true !default;\n$enable-important-utilities: true !default;\n\n$enable-dark-mode: true !default;\n$color-mode-type: data !default; // `data` or `media-query`\n\n// Prefix for :root CSS variables\n\n$variable-prefix: bs- !default; // Deprecated in v5.2.0 for the shorter `$prefix`\n$prefix: $variable-prefix !default;\n\n// Gradient\n//\n// The gradient which is added to components if `$enable-gradients` is `true`\n// This gradient is also added to elements with `.bg-gradient`\n// scss-docs-start variable-gradient\n$gradient: linear-gradient(180deg, rgba($white, .15), rgba($white, 0)) !default;\n// scss-docs-end variable-gradient\n\n// Spacing\n//\n// Control the default styling of most Bootstrap elements by modifying these\n// variables. Mostly focused on spacing.\n// You can add more entries to the $spacers map, should you need more variation.\n\n// scss-docs-start spacer-variables-maps\n$spacer: 1rem !default;\n$spacers: (\n 0: 0,\n 1: $spacer * .25,\n 2: $spacer * .5,\n 3: $spacer,\n 4: $spacer * 1.5,\n 5: $spacer * 3,\n) !default;\n// scss-docs-end spacer-variables-maps\n\n// Position\n//\n// Define the edge positioning anchors of the position utilities.\n\n// scss-docs-start position-map\n$position-values: (\n 0: 0,\n 50: 50%,\n 100: 100%\n) !default;\n// scss-docs-end position-map\n\n// Body\n//\n// Settings for the `` element.\n\n$body-text-align: null !default;\n$body-color: $gray-900 !default;\n$body-bg: $white !default;\n\n$body-secondary-color: rgba($body-color, .75) !default;\n$body-secondary-bg: $gray-200 !default;\n\n$body-tertiary-color: rgba($body-color, .5) !default;\n$body-tertiary-bg: $gray-100 !default;\n\n$body-emphasis-color: $black !default;\n\n// Links\n//\n// Style anchor elements.\n\n$link-color: $primary !default;\n$link-decoration: underline !default;\n$link-shade-percentage: 20% !default;\n$link-hover-color: shift-color($link-color, $link-shade-percentage) !default;\n$link-hover-decoration: null !default;\n\n$stretched-link-pseudo-element: after !default;\n$stretched-link-z-index: 1 !default;\n\n// Icon links\n// scss-docs-start icon-link-variables\n$icon-link-gap: .375rem !default;\n$icon-link-underline-offset: .25em !default;\n$icon-link-icon-size: 1em !default;\n$icon-link-icon-transition: .2s ease-in-out transform !default;\n$icon-link-icon-transform: translate3d(.25em, 0, 0) !default;\n// scss-docs-end icon-link-variables\n\n// Paragraphs\n//\n// Style p element.\n\n$paragraph-margin-bottom: 1rem !default;\n\n\n// Grid breakpoints\n//\n// Define the minimum dimensions at which your layout will change,\n// adapting to different screen sizes, for use in media queries.\n\n// scss-docs-start grid-breakpoints\n$grid-breakpoints: (\n xs: 0,\n sm: 576px,\n md: 768px,\n lg: 992px,\n xl: 1200px,\n xxl: 1400px\n) !default;\n// scss-docs-end grid-breakpoints\n\n@include _assert-ascending($grid-breakpoints, \"$grid-breakpoints\");\n@include _assert-starts-at-zero($grid-breakpoints, \"$grid-breakpoints\");\n\n\n// Grid containers\n//\n// Define the maximum width of `.container` for different screen sizes.\n\n// scss-docs-start container-max-widths\n$container-max-widths: (\n sm: 540px,\n md: 720px,\n lg: 960px,\n xl: 1140px,\n xxl: 1320px\n) !default;\n// scss-docs-end container-max-widths\n\n@include _assert-ascending($container-max-widths, \"$container-max-widths\");\n\n\n// Grid columns\n//\n// Set the number of columns and specify the width of the gutters.\n\n$grid-columns: 12 !default;\n$grid-gutter-width: 1.5rem !default;\n$grid-row-columns: 6 !default;\n\n// Container padding\n\n$container-padding-x: $grid-gutter-width !default;\n\n\n// Components\n//\n// Define common padding and border radius sizes and more.\n\n// scss-docs-start border-variables\n$border-width: 1px !default;\n$border-widths: (\n 1: 1px,\n 2: 2px,\n 3: 3px,\n 4: 4px,\n 5: 5px\n) !default;\n$border-style: solid !default;\n$border-color: $gray-300 !default;\n$border-color-translucent: rgba($black, .175) !default;\n// scss-docs-end border-variables\n\n// scss-docs-start border-radius-variables\n$border-radius: .375rem !default;\n$border-radius-sm: .25rem !default;\n$border-radius-lg: .5rem !default;\n$border-radius-xl: 1rem !default;\n$border-radius-xxl: 2rem !default;\n$border-radius-pill: 50rem !default;\n// scss-docs-end border-radius-variables\n// fusv-disable\n$border-radius-2xl: $border-radius-xxl !default; // Deprecated in v5.3.0\n// fusv-enable\n\n// scss-docs-start box-shadow-variables\n$box-shadow: 0 .5rem 1rem rgba($black, .15) !default;\n$box-shadow-sm: 0 .125rem .25rem rgba($black, .075) !default;\n$box-shadow-lg: 0 1rem 3rem rgba($black, .175) !default;\n$box-shadow-inset: inset 0 1px 2px rgba($black, .075) !default;\n// scss-docs-end box-shadow-variables\n\n$component-active-color: $white !default;\n$component-active-bg: $primary !default;\n\n// scss-docs-start focus-ring-variables\n$focus-ring-width: .25rem !default;\n$focus-ring-opacity: .25 !default;\n$focus-ring-color: rgba($primary, $focus-ring-opacity) !default;\n$focus-ring-blur: 0 !default;\n$focus-ring-box-shadow: 0 0 $focus-ring-blur $focus-ring-width $focus-ring-color !default;\n// scss-docs-end focus-ring-variables\n\n// scss-docs-start caret-variables\n$caret-width: .3em !default;\n$caret-vertical-align: $caret-width * .85 !default;\n$caret-spacing: $caret-width * .85 !default;\n// scss-docs-end caret-variables\n\n$transition-base: all .2s ease-in-out !default;\n$transition-fade: opacity .15s linear !default;\n// scss-docs-start collapse-transition\n$transition-collapse: height .35s ease !default;\n$transition-collapse-width: width .35s ease !default;\n// scss-docs-end collapse-transition\n\n// stylelint-disable function-disallowed-list\n// scss-docs-start aspect-ratios\n$aspect-ratios: (\n \"1x1\": 100%,\n \"4x3\": calc(3 / 4 * 100%),\n \"16x9\": calc(9 / 16 * 100%),\n \"21x9\": calc(9 / 21 * 100%)\n) !default;\n// scss-docs-end aspect-ratios\n// stylelint-enable function-disallowed-list\n\n// Typography\n//\n// Font, line-height, and color for body text, headings, and more.\n\n// scss-docs-start font-variables\n// stylelint-disable value-keyword-case\n$font-family-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\" !default;\n$font-family-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace !default;\n// stylelint-enable value-keyword-case\n$font-family-base: var(--#{$prefix}font-sans-serif) !default;\n$font-family-code: var(--#{$prefix}font-monospace) !default;\n\n// $font-size-root affects the value of `rem`, which is used for as well font sizes, paddings, and margins\n// $font-size-base affects the font size of the body text\n$font-size-root: null !default;\n$font-size-base: 1rem !default; // Assumes the browser default, typically `16px`\n$font-size-sm: $font-size-base * .875 !default;\n$font-size-lg: $font-size-base * 1.25 !default;\n\n$font-weight-lighter: lighter !default;\n$font-weight-light: 300 !default;\n$font-weight-normal: 400 !default;\n$font-weight-medium: 500 !default;\n$font-weight-semibold: 600 !default;\n$font-weight-bold: 700 !default;\n$font-weight-bolder: bolder !default;\n\n$font-weight-base: $font-weight-normal !default;\n\n$line-height-base: 1.5 !default;\n$line-height-sm: 1.25 !default;\n$line-height-lg: 2 !default;\n\n$h1-font-size: $font-size-base * 2.5 !default;\n$h2-font-size: $font-size-base * 2 !default;\n$h3-font-size: $font-size-base * 1.75 !default;\n$h4-font-size: $font-size-base * 1.5 !default;\n$h5-font-size: $font-size-base * 1.25 !default;\n$h6-font-size: $font-size-base !default;\n// scss-docs-end font-variables\n\n// scss-docs-start font-sizes\n$font-sizes: (\n 1: $h1-font-size,\n 2: $h2-font-size,\n 3: $h3-font-size,\n 4: $h4-font-size,\n 5: $h5-font-size,\n 6: $h6-font-size\n) !default;\n// scss-docs-end font-sizes\n\n// scss-docs-start headings-variables\n$headings-margin-bottom: $spacer * .5 !default;\n$headings-font-family: null !default;\n$headings-font-style: null !default;\n$headings-font-weight: 500 !default;\n$headings-line-height: 1.2 !default;\n$headings-color: inherit !default;\n// scss-docs-end headings-variables\n\n// scss-docs-start display-headings\n$display-font-sizes: (\n 1: 5rem,\n 2: 4.5rem,\n 3: 4rem,\n 4: 3.5rem,\n 5: 3rem,\n 6: 2.5rem\n) !default;\n\n$display-font-family: null !default;\n$display-font-style: null !default;\n$display-font-weight: 300 !default;\n$display-line-height: $headings-line-height !default;\n// scss-docs-end display-headings\n\n// scss-docs-start type-variables\n$lead-font-size: $font-size-base * 1.25 !default;\n$lead-font-weight: 300 !default;\n\n$small-font-size: .875em !default;\n\n$sub-sup-font-size: .75em !default;\n\n// fusv-disable\n$text-muted: var(--#{$prefix}secondary-color) !default; // Deprecated in 5.3.0\n// fusv-enable\n\n$initialism-font-size: $small-font-size !default;\n\n$blockquote-margin-y: $spacer !default;\n$blockquote-font-size: $font-size-base * 1.25 !default;\n$blockquote-footer-color: $gray-600 !default;\n$blockquote-footer-font-size: $small-font-size !default;\n\n$hr-margin-y: $spacer !default;\n$hr-color: inherit !default;\n\n// fusv-disable\n$hr-bg-color: null !default; // Deprecated in v5.2.0\n$hr-height: null !default; // Deprecated in v5.2.0\n// fusv-enable\n\n$hr-border-color: null !default; // Allows for inherited colors\n$hr-border-width: var(--#{$prefix}border-width) !default;\n$hr-opacity: .25 !default;\n\n// scss-docs-start vr-variables\n$vr-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end vr-variables\n\n$legend-margin-bottom: .5rem !default;\n$legend-font-size: 1.5rem !default;\n$legend-font-weight: null !default;\n\n$dt-font-weight: $font-weight-bold !default;\n\n$list-inline-padding: .5rem !default;\n\n$mark-padding: .1875em !default;\n$mark-color: $body-color !default;\n$mark-bg: $yellow-100 !default;\n// scss-docs-end type-variables\n\n\n// Tables\n//\n// Customizes the `.table` component with basic values, each used across all table variations.\n\n// scss-docs-start table-variables\n$table-cell-padding-y: .5rem !default;\n$table-cell-padding-x: .5rem !default;\n$table-cell-padding-y-sm: .25rem !default;\n$table-cell-padding-x-sm: .25rem !default;\n\n$table-cell-vertical-align: top !default;\n\n$table-color: var(--#{$prefix}emphasis-color) !default;\n$table-bg: var(--#{$prefix}body-bg) !default;\n$table-accent-bg: transparent !default;\n\n$table-th-font-weight: null !default;\n\n$table-striped-color: $table-color !default;\n$table-striped-bg-factor: .05 !default;\n$table-striped-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-striped-bg-factor) !default;\n\n$table-active-color: $table-color !default;\n$table-active-bg-factor: .1 !default;\n$table-active-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-active-bg-factor) !default;\n\n$table-hover-color: $table-color !default;\n$table-hover-bg-factor: .075 !default;\n$table-hover-bg: rgba(var(--#{$prefix}emphasis-color-rgb), $table-hover-bg-factor) !default;\n\n$table-border-factor: .2 !default;\n$table-border-width: var(--#{$prefix}border-width) !default;\n$table-border-color: var(--#{$prefix}border-color) !default;\n\n$table-striped-order: odd !default;\n$table-striped-columns-order: even !default;\n\n$table-group-separator-color: currentcolor !default;\n\n$table-caption-color: var(--#{$prefix}secondary-color) !default;\n\n$table-bg-scale: -80% !default;\n// scss-docs-end table-variables\n\n// scss-docs-start table-loop\n$table-variants: (\n \"primary\": shift-color($primary, $table-bg-scale),\n \"secondary\": shift-color($secondary, $table-bg-scale),\n \"success\": shift-color($success, $table-bg-scale),\n \"info\": shift-color($info, $table-bg-scale),\n \"warning\": shift-color($warning, $table-bg-scale),\n \"danger\": shift-color($danger, $table-bg-scale),\n \"light\": $light,\n \"dark\": $dark,\n) !default;\n// scss-docs-end table-loop\n\n\n// Buttons + Forms\n//\n// Shared variables that are reassigned to `$input-` and `$btn-` specific variables.\n\n// scss-docs-start input-btn-variables\n$input-btn-padding-y: .375rem !default;\n$input-btn-padding-x: .75rem !default;\n$input-btn-font-family: null !default;\n$input-btn-font-size: $font-size-base !default;\n$input-btn-line-height: $line-height-base !default;\n\n$input-btn-focus-width: $focus-ring-width !default;\n$input-btn-focus-color-opacity: $focus-ring-opacity !default;\n$input-btn-focus-color: $focus-ring-color !default;\n$input-btn-focus-blur: $focus-ring-blur !default;\n$input-btn-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$input-btn-padding-y-sm: .25rem !default;\n$input-btn-padding-x-sm: .5rem !default;\n$input-btn-font-size-sm: $font-size-sm !default;\n\n$input-btn-padding-y-lg: .5rem !default;\n$input-btn-padding-x-lg: 1rem !default;\n$input-btn-font-size-lg: $font-size-lg !default;\n\n$input-btn-border-width: var(--#{$prefix}border-width) !default;\n// scss-docs-end input-btn-variables\n\n\n// Buttons\n//\n// For each of Bootstrap's buttons, define text, background, and border color.\n\n// scss-docs-start btn-variables\n$btn-color: var(--#{$prefix}body-color) !default;\n$btn-padding-y: $input-btn-padding-y !default;\n$btn-padding-x: $input-btn-padding-x !default;\n$btn-font-family: $input-btn-font-family !default;\n$btn-font-size: $input-btn-font-size !default;\n$btn-line-height: $input-btn-line-height !default;\n$btn-white-space: null !default; // Set to `nowrap` to prevent text wrapping\n\n$btn-padding-y-sm: $input-btn-padding-y-sm !default;\n$btn-padding-x-sm: $input-btn-padding-x-sm !default;\n$btn-font-size-sm: $input-btn-font-size-sm !default;\n\n$btn-padding-y-lg: $input-btn-padding-y-lg !default;\n$btn-padding-x-lg: $input-btn-padding-x-lg !default;\n$btn-font-size-lg: $input-btn-font-size-lg !default;\n\n$btn-border-width: $input-btn-border-width !default;\n\n$btn-font-weight: $font-weight-normal !default;\n$btn-box-shadow: inset 0 1px 0 rgba($white, .15), 0 1px 1px rgba($black, .075) !default;\n$btn-focus-width: $input-btn-focus-width !default;\n$btn-focus-box-shadow: $input-btn-focus-box-shadow !default;\n$btn-disabled-opacity: .65 !default;\n$btn-active-box-shadow: inset 0 3px 5px rgba($black, .125) !default;\n\n$btn-link-color: var(--#{$prefix}link-color) !default;\n$btn-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$btn-link-disabled-color: $gray-600 !default;\n$btn-link-focus-shadow-rgb: to-rgb(mix(color-contrast($link-color), $link-color, 15%)) !default;\n\n// Allows for customizing button radius independently from global border radius\n$btn-border-radius: var(--#{$prefix}border-radius) !default;\n$btn-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$btn-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$btn-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$btn-hover-bg-shade-amount: 15% !default;\n$btn-hover-bg-tint-amount: 15% !default;\n$btn-hover-border-shade-amount: 20% !default;\n$btn-hover-border-tint-amount: 10% !default;\n$btn-active-bg-shade-amount: 20% !default;\n$btn-active-bg-tint-amount: 20% !default;\n$btn-active-border-shade-amount: 25% !default;\n$btn-active-border-tint-amount: 10% !default;\n// scss-docs-end btn-variables\n\n\n// Forms\n\n// scss-docs-start form-text-variables\n$form-text-margin-top: .25rem !default;\n$form-text-font-size: $small-font-size !default;\n$form-text-font-style: null !default;\n$form-text-font-weight: null !default;\n$form-text-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end form-text-variables\n\n// scss-docs-start form-label-variables\n$form-label-margin-bottom: .5rem !default;\n$form-label-font-size: null !default;\n$form-label-font-style: null !default;\n$form-label-font-weight: null !default;\n$form-label-color: null !default;\n// scss-docs-end form-label-variables\n\n// scss-docs-start form-input-variables\n$input-padding-y: $input-btn-padding-y !default;\n$input-padding-x: $input-btn-padding-x !default;\n$input-font-family: $input-btn-font-family !default;\n$input-font-size: $input-btn-font-size !default;\n$input-font-weight: $font-weight-base !default;\n$input-line-height: $input-btn-line-height !default;\n\n$input-padding-y-sm: $input-btn-padding-y-sm !default;\n$input-padding-x-sm: $input-btn-padding-x-sm !default;\n$input-font-size-sm: $input-btn-font-size-sm !default;\n\n$input-padding-y-lg: $input-btn-padding-y-lg !default;\n$input-padding-x-lg: $input-btn-padding-x-lg !default;\n$input-font-size-lg: $input-btn-font-size-lg !default;\n\n$input-bg: var(--#{$prefix}body-bg) !default;\n$input-disabled-color: null !default;\n$input-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$input-disabled-border-color: null !default;\n\n$input-color: var(--#{$prefix}body-color) !default;\n$input-border-color: var(--#{$prefix}border-color) !default;\n$input-border-width: $input-btn-border-width !default;\n$input-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$input-border-radius: var(--#{$prefix}border-radius) !default;\n$input-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$input-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n\n$input-focus-bg: $input-bg !default;\n$input-focus-border-color: tint-color($component-active-bg, 50%) !default;\n$input-focus-color: $input-color !default;\n$input-focus-width: $input-btn-focus-width !default;\n$input-focus-box-shadow: $input-btn-focus-box-shadow !default;\n\n$input-placeholder-color: var(--#{$prefix}secondary-color) !default;\n$input-plaintext-color: var(--#{$prefix}body-color) !default;\n\n$input-height-border: calc(#{$input-border-width} * 2) !default; // stylelint-disable-line function-disallowed-list\n\n$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2) !default;\n$input-height-inner-half: add($input-line-height * .5em, $input-padding-y) !default;\n$input-height-inner-quarter: add($input-line-height * .25em, $input-padding-y * .5) !default;\n\n$input-height: add($input-line-height * 1em, add($input-padding-y * 2, $input-height-border, false)) !default;\n$input-height-sm: add($input-line-height * 1em, add($input-padding-y-sm * 2, $input-height-border, false)) !default;\n$input-height-lg: add($input-line-height * 1em, add($input-padding-y-lg * 2, $input-height-border, false)) !default;\n\n$input-transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$form-color-width: 3rem !default;\n// scss-docs-end form-input-variables\n\n// scss-docs-start form-check-variables\n$form-check-input-width: 1em !default;\n$form-check-min-height: $font-size-base * $line-height-base !default;\n$form-check-padding-start: $form-check-input-width + .5em !default;\n$form-check-margin-bottom: .125rem !default;\n$form-check-label-color: null !default;\n$form-check-label-cursor: null !default;\n$form-check-transition: null !default;\n\n$form-check-input-active-filter: brightness(90%) !default;\n\n$form-check-input-bg: $input-bg !default;\n$form-check-input-border: var(--#{$prefix}border-width) solid var(--#{$prefix}border-color) !default;\n$form-check-input-border-radius: .25em !default;\n$form-check-radio-border-radius: 50% !default;\n$form-check-input-focus-border: $input-focus-border-color !default;\n$form-check-input-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$form-check-input-checked-color: $component-active-color !default;\n$form-check-input-checked-bg-color: $component-active-bg !default;\n$form-check-input-checked-border-color: $form-check-input-checked-bg-color !default;\n$form-check-input-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-check-radio-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-indeterminate-color: $component-active-color !default;\n$form-check-input-indeterminate-bg-color: $component-active-bg !default;\n$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color !default;\n$form-check-input-indeterminate-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-check-input-disabled-opacity: .5 !default;\n$form-check-label-disabled-opacity: $form-check-input-disabled-opacity !default;\n$form-check-btn-check-disabled-opacity: $btn-disabled-opacity !default;\n\n$form-check-inline-margin-end: 1rem !default;\n// scss-docs-end form-check-variables\n\n// scss-docs-start form-switch-variables\n$form-switch-color: rgba($black, .25) !default;\n$form-switch-width: 2em !default;\n$form-switch-padding-start: $form-switch-width + .5em !default;\n$form-switch-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-border-radius: $form-switch-width !default;\n$form-switch-transition: background-position .15s ease-in-out !default;\n\n$form-switch-focus-color: $input-focus-border-color !default;\n$form-switch-focus-bg-image: url(\"data:image/svg+xml,\") !default;\n\n$form-switch-checked-color: $component-active-color !default;\n$form-switch-checked-bg-image: url(\"data:image/svg+xml,\") !default;\n$form-switch-checked-bg-position: right center !default;\n// scss-docs-end form-switch-variables\n\n// scss-docs-start input-group-variables\n$input-group-addon-padding-y: $input-padding-y !default;\n$input-group-addon-padding-x: $input-padding-x !default;\n$input-group-addon-font-weight: $input-font-weight !default;\n$input-group-addon-color: $input-color !default;\n$input-group-addon-bg: var(--#{$prefix}tertiary-bg) !default;\n$input-group-addon-border-color: $input-border-color !default;\n// scss-docs-end input-group-variables\n\n// scss-docs-start form-select-variables\n$form-select-padding-y: $input-padding-y !default;\n$form-select-padding-x: $input-padding-x !default;\n$form-select-font-family: $input-font-family !default;\n$form-select-font-size: $input-font-size !default;\n$form-select-indicator-padding: $form-select-padding-x * 3 !default; // Extra padding for background-image\n$form-select-font-weight: $input-font-weight !default;\n$form-select-line-height: $input-line-height !default;\n$form-select-color: $input-color !default;\n$form-select-bg: $input-bg !default;\n$form-select-disabled-color: null !default;\n$form-select-disabled-bg: $input-disabled-bg !default;\n$form-select-disabled-border-color: $input-disabled-border-color !default;\n$form-select-bg-position: right $form-select-padding-x center !default;\n$form-select-bg-size: 16px 12px !default; // In pixels because image dimensions\n$form-select-indicator-color: $gray-800 !default;\n$form-select-indicator: url(\"data:image/svg+xml,\") !default;\n\n$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + $form-select-indicator-padding !default;\n$form-select-feedback-icon-position: center right $form-select-indicator-padding !default;\n$form-select-feedback-icon-size: $input-height-inner-half $input-height-inner-half !default;\n\n$form-select-border-width: $input-border-width !default;\n$form-select-border-color: $input-border-color !default;\n$form-select-border-radius: $input-border-radius !default;\n$form-select-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-select-focus-border-color: $input-focus-border-color !default;\n$form-select-focus-width: $input-focus-width !default;\n$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width $input-btn-focus-color !default;\n\n$form-select-padding-y-sm: $input-padding-y-sm !default;\n$form-select-padding-x-sm: $input-padding-x-sm !default;\n$form-select-font-size-sm: $input-font-size-sm !default;\n$form-select-border-radius-sm: $input-border-radius-sm !default;\n\n$form-select-padding-y-lg: $input-padding-y-lg !default;\n$form-select-padding-x-lg: $input-padding-x-lg !default;\n$form-select-font-size-lg: $input-font-size-lg !default;\n$form-select-border-radius-lg: $input-border-radius-lg !default;\n\n$form-select-transition: $input-transition !default;\n// scss-docs-end form-select-variables\n\n// scss-docs-start form-range-variables\n$form-range-track-width: 100% !default;\n$form-range-track-height: .5rem !default;\n$form-range-track-cursor: pointer !default;\n$form-range-track-bg: var(--#{$prefix}secondary-bg) !default;\n$form-range-track-border-radius: 1rem !default;\n$form-range-track-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n\n$form-range-thumb-width: 1rem !default;\n$form-range-thumb-height: $form-range-thumb-width !default;\n$form-range-thumb-bg: $component-active-bg !default;\n$form-range-thumb-border: 0 !default;\n$form-range-thumb-border-radius: 1rem !default;\n$form-range-thumb-box-shadow: 0 .1rem .25rem rgba($black, .1) !default;\n$form-range-thumb-focus-box-shadow: 0 0 0 1px $body-bg, $input-focus-box-shadow !default;\n$form-range-thumb-focus-box-shadow-width: $input-focus-width !default; // For focus box shadow issue in Edge\n$form-range-thumb-active-bg: tint-color($component-active-bg, 70%) !default;\n$form-range-thumb-disabled-bg: var(--#{$prefix}secondary-color) !default;\n$form-range-thumb-transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n// scss-docs-end form-range-variables\n\n// scss-docs-start form-file-variables\n$form-file-button-color: $input-color !default;\n$form-file-button-bg: var(--#{$prefix}tertiary-bg) !default;\n$form-file-button-hover-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end form-file-variables\n\n// scss-docs-start form-floating-variables\n$form-floating-height: add(3.5rem, $input-height-border) !default;\n$form-floating-line-height: 1.25 !default;\n$form-floating-padding-x: $input-padding-x !default;\n$form-floating-padding-y: 1rem !default;\n$form-floating-input-padding-t: 1.625rem !default;\n$form-floating-input-padding-b: .625rem !default;\n$form-floating-label-height: 1.5em !default;\n$form-floating-label-opacity: .65 !default;\n$form-floating-label-transform: scale(.85) translateY(-.5rem) translateX(.15rem) !default;\n$form-floating-label-disabled-color: $gray-600 !default;\n$form-floating-transition: opacity .1s ease-in-out, transform .1s ease-in-out !default;\n// scss-docs-end form-floating-variables\n\n// Form validation\n\n// scss-docs-start form-feedback-variables\n$form-feedback-margin-top: $form-text-margin-top !default;\n$form-feedback-font-size: $form-text-font-size !default;\n$form-feedback-font-style: $form-text-font-style !default;\n$form-feedback-valid-color: $success !default;\n$form-feedback-invalid-color: $danger !default;\n\n$form-feedback-icon-valid-color: $form-feedback-valid-color !default;\n$form-feedback-icon-valid: url(\"data:image/svg+xml,\") !default;\n$form-feedback-icon-invalid-color: $form-feedback-invalid-color !default;\n$form-feedback-icon-invalid: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end form-feedback-variables\n\n// scss-docs-start form-validation-colors\n$form-valid-color: $form-feedback-valid-color !default;\n$form-valid-border-color: $form-feedback-valid-color !default;\n$form-invalid-color: $form-feedback-invalid-color !default;\n$form-invalid-border-color: $form-feedback-invalid-color !default;\n// scss-docs-end form-validation-colors\n\n// scss-docs-start form-validation-states\n$form-validation-states: (\n \"valid\": (\n \"color\": var(--#{$prefix}form-valid-color),\n \"icon\": $form-feedback-icon-valid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}success),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}success-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-valid-border-color),\n ),\n \"invalid\": (\n \"color\": var(--#{$prefix}form-invalid-color),\n \"icon\": $form-feedback-icon-invalid,\n \"tooltip-color\": #fff,\n \"tooltip-bg-color\": var(--#{$prefix}danger),\n \"focus-box-shadow\": 0 0 $input-btn-focus-blur $input-focus-width rgba(var(--#{$prefix}danger-rgb), $input-btn-focus-color-opacity),\n \"border-color\": var(--#{$prefix}form-invalid-border-color),\n )\n) !default;\n// scss-docs-end form-validation-states\n\n// Z-index master list\n//\n// Warning: Avoid customizing these values. They're used for a bird's eye view\n// of components dependent on the z-axis and are designed to all work together.\n\n// scss-docs-start zindex-stack\n$zindex-dropdown: 1000 !default;\n$zindex-sticky: 1020 !default;\n$zindex-fixed: 1030 !default;\n$zindex-offcanvas-backdrop: 1040 !default;\n$zindex-offcanvas: 1045 !default;\n$zindex-modal-backdrop: 1050 !default;\n$zindex-modal: 1055 !default;\n$zindex-popover: 1070 !default;\n$zindex-tooltip: 1080 !default;\n$zindex-toast: 1090 !default;\n// scss-docs-end zindex-stack\n\n// scss-docs-start zindex-levels-map\n$zindex-levels: (\n n1: -1,\n 0: 0,\n 1: 1,\n 2: 2,\n 3: 3\n) !default;\n// scss-docs-end zindex-levels-map\n\n\n// Navs\n\n// scss-docs-start nav-variables\n$nav-link-padding-y: .5rem !default;\n$nav-link-padding-x: 1rem !default;\n$nav-link-font-size: null !default;\n$nav-link-font-weight: null !default;\n$nav-link-color: var(--#{$prefix}link-color) !default;\n$nav-link-hover-color: var(--#{$prefix}link-hover-color) !default;\n$nav-link-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out !default;\n$nav-link-disabled-color: var(--#{$prefix}secondary-color) !default;\n$nav-link-focus-box-shadow: $focus-ring-box-shadow !default;\n\n$nav-tabs-border-color: var(--#{$prefix}border-color) !default;\n$nav-tabs-border-width: var(--#{$prefix}border-width) !default;\n$nav-tabs-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-tabs-link-hover-border-color: var(--#{$prefix}secondary-bg) var(--#{$prefix}secondary-bg) $nav-tabs-border-color !default;\n$nav-tabs-link-active-color: var(--#{$prefix}emphasis-color) !default;\n$nav-tabs-link-active-bg: var(--#{$prefix}body-bg) !default;\n$nav-tabs-link-active-border-color: var(--#{$prefix}border-color) var(--#{$prefix}border-color) $nav-tabs-link-active-bg !default;\n\n$nav-pills-border-radius: var(--#{$prefix}border-radius) !default;\n$nav-pills-link-active-color: $component-active-color !default;\n$nav-pills-link-active-bg: $component-active-bg !default;\n\n$nav-underline-gap: 1rem !default;\n$nav-underline-border-width: .125rem !default;\n$nav-underline-link-active-color: var(--#{$prefix}emphasis-color) !default;\n// scss-docs-end nav-variables\n\n\n// Navbar\n\n// scss-docs-start navbar-variables\n$navbar-padding-y: $spacer * .5 !default;\n$navbar-padding-x: null !default;\n\n$navbar-nav-link-padding-x: .5rem !default;\n\n$navbar-brand-font-size: $font-size-lg !default;\n// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link\n$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default;\n$navbar-brand-height: $navbar-brand-font-size * $line-height-base !default;\n$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default;\n$navbar-brand-margin-end: 1rem !default;\n\n$navbar-toggler-padding-y: .25rem !default;\n$navbar-toggler-padding-x: .75rem !default;\n$navbar-toggler-font-size: $font-size-lg !default;\n$navbar-toggler-border-radius: $btn-border-radius !default;\n$navbar-toggler-focus-width: $btn-focus-width !default;\n$navbar-toggler-transition: box-shadow .15s ease-in-out !default;\n\n$navbar-light-color: rgba(var(--#{$prefix}emphasis-color-rgb), .65) !default;\n$navbar-light-hover-color: rgba(var(--#{$prefix}emphasis-color-rgb), .8) !default;\n$navbar-light-active-color: rgba(var(--#{$prefix}emphasis-color-rgb), 1) !default;\n$navbar-light-disabled-color: rgba(var(--#{$prefix}emphasis-color-rgb), .3) !default;\n$navbar-light-icon-color: rgba($body-color, .75) !default;\n$navbar-light-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-light-toggler-border-color: rgba(var(--#{$prefix}emphasis-color-rgb), .15) !default;\n$navbar-light-brand-color: $navbar-light-active-color !default;\n$navbar-light-brand-hover-color: $navbar-light-active-color !default;\n// scss-docs-end navbar-variables\n\n// scss-docs-start navbar-dark-variables\n$navbar-dark-color: rgba($white, .55) !default;\n$navbar-dark-hover-color: rgba($white, .75) !default;\n$navbar-dark-active-color: $white !default;\n$navbar-dark-disabled-color: rgba($white, .25) !default;\n$navbar-dark-icon-color: $navbar-dark-color !default;\n$navbar-dark-toggler-icon-bg: url(\"data:image/svg+xml,\") !default;\n$navbar-dark-toggler-border-color: rgba($white, .1) !default;\n$navbar-dark-brand-color: $navbar-dark-active-color !default;\n$navbar-dark-brand-hover-color: $navbar-dark-active-color !default;\n// scss-docs-end navbar-dark-variables\n\n\n// Dropdowns\n//\n// Dropdown menu container and contents.\n\n// scss-docs-start dropdown-variables\n$dropdown-min-width: 10rem !default;\n$dropdown-padding-x: 0 !default;\n$dropdown-padding-y: .5rem !default;\n$dropdown-spacer: .125rem !default;\n$dropdown-font-size: $font-size-base !default;\n$dropdown-color: var(--#{$prefix}body-color) !default;\n$dropdown-bg: var(--#{$prefix}body-bg) !default;\n$dropdown-border-color: var(--#{$prefix}border-color-translucent) !default;\n$dropdown-border-radius: var(--#{$prefix}border-radius) !default;\n$dropdown-border-width: var(--#{$prefix}border-width) !default;\n$dropdown-inner-border-radius: calc(#{$dropdown-border-radius} - #{$dropdown-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$dropdown-divider-bg: $dropdown-border-color !default;\n$dropdown-divider-margin-y: $spacer * .5 !default;\n$dropdown-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$dropdown-link-color: var(--#{$prefix}body-color) !default;\n$dropdown-link-hover-color: $dropdown-link-color !default;\n$dropdown-link-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n\n$dropdown-link-active-color: $component-active-color !default;\n$dropdown-link-active-bg: $component-active-bg !default;\n\n$dropdown-link-disabled-color: var(--#{$prefix}tertiary-color) !default;\n\n$dropdown-item-padding-y: $spacer * .25 !default;\n$dropdown-item-padding-x: $spacer !default;\n\n$dropdown-header-color: $gray-600 !default;\n$dropdown-header-padding-x: $dropdown-item-padding-x !default;\n$dropdown-header-padding-y: $dropdown-padding-y !default;\n// fusv-disable\n$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x !default; // Deprecated in v5.2.0\n// fusv-enable\n// scss-docs-end dropdown-variables\n\n// scss-docs-start dropdown-dark-variables\n$dropdown-dark-color: $gray-300 !default;\n$dropdown-dark-bg: $gray-800 !default;\n$dropdown-dark-border-color: $dropdown-border-color !default;\n$dropdown-dark-divider-bg: $dropdown-divider-bg !default;\n$dropdown-dark-box-shadow: null !default;\n$dropdown-dark-link-color: $dropdown-dark-color !default;\n$dropdown-dark-link-hover-color: $white !default;\n$dropdown-dark-link-hover-bg: rgba($white, .15) !default;\n$dropdown-dark-link-active-color: $dropdown-link-active-color !default;\n$dropdown-dark-link-active-bg: $dropdown-link-active-bg !default;\n$dropdown-dark-link-disabled-color: $gray-500 !default;\n$dropdown-dark-header-color: $gray-500 !default;\n// scss-docs-end dropdown-dark-variables\n\n\n// Pagination\n\n// scss-docs-start pagination-variables\n$pagination-padding-y: .375rem !default;\n$pagination-padding-x: .75rem !default;\n$pagination-padding-y-sm: .25rem !default;\n$pagination-padding-x-sm: .5rem !default;\n$pagination-padding-y-lg: .75rem !default;\n$pagination-padding-x-lg: 1.5rem !default;\n\n$pagination-font-size: $font-size-base !default;\n\n$pagination-color: var(--#{$prefix}link-color) !default;\n$pagination-bg: var(--#{$prefix}body-bg) !default;\n$pagination-border-radius: var(--#{$prefix}border-radius) !default;\n$pagination-border-width: var(--#{$prefix}border-width) !default;\n$pagination-margin-start: calc(#{$pagination-border-width} * -1) !default; // stylelint-disable-line function-disallowed-list\n$pagination-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-focus-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-focus-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-focus-box-shadow: $focus-ring-box-shadow !default;\n$pagination-focus-outline: 0 !default;\n\n$pagination-hover-color: var(--#{$prefix}link-hover-color) !default;\n$pagination-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$pagination-hover-border-color: var(--#{$prefix}border-color) !default; // Todo in v6: remove this?\n\n$pagination-active-color: $component-active-color !default;\n$pagination-active-bg: $component-active-bg !default;\n$pagination-active-border-color: $component-active-bg !default;\n\n$pagination-disabled-color: var(--#{$prefix}secondary-color) !default;\n$pagination-disabled-bg: var(--#{$prefix}secondary-bg) !default;\n$pagination-disabled-border-color: var(--#{$prefix}border-color) !default;\n\n$pagination-transition: color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out !default;\n\n$pagination-border-radius-sm: var(--#{$prefix}border-radius-sm) !default;\n$pagination-border-radius-lg: var(--#{$prefix}border-radius-lg) !default;\n// scss-docs-end pagination-variables\n\n\n// Placeholders\n\n// scss-docs-start placeholders\n$placeholder-opacity-max: .5 !default;\n$placeholder-opacity-min: .2 !default;\n// scss-docs-end placeholders\n\n// Cards\n\n// scss-docs-start card-variables\n$card-spacer-y: $spacer !default;\n$card-spacer-x: $spacer !default;\n$card-title-spacer-y: $spacer * .5 !default;\n$card-title-color: null !default;\n$card-subtitle-color: null !default;\n$card-border-width: var(--#{$prefix}border-width) !default;\n$card-border-color: var(--#{$prefix}border-color-translucent) !default;\n$card-border-radius: var(--#{$prefix}border-radius) !default;\n$card-box-shadow: null !default;\n$card-inner-border-radius: subtract($card-border-radius, $card-border-width) !default;\n$card-cap-padding-y: $card-spacer-y * .5 !default;\n$card-cap-padding-x: $card-spacer-x !default;\n$card-cap-bg: rgba(var(--#{$prefix}body-color-rgb), .03) !default;\n$card-cap-color: null !default;\n$card-height: null !default;\n$card-color: null !default;\n$card-bg: var(--#{$prefix}body-bg) !default;\n$card-img-overlay-padding: $spacer !default;\n$card-group-margin: $grid-gutter-width * .5 !default;\n// scss-docs-end card-variables\n\n// Accordion\n\n// scss-docs-start accordion-variables\n$accordion-padding-y: 1rem !default;\n$accordion-padding-x: 1.25rem !default;\n$accordion-color: var(--#{$prefix}body-color) !default;\n$accordion-bg: var(--#{$prefix}body-bg) !default;\n$accordion-border-width: var(--#{$prefix}border-width) !default;\n$accordion-border-color: var(--#{$prefix}border-color) !default;\n$accordion-border-radius: var(--#{$prefix}border-radius) !default;\n$accordion-inner-border-radius: subtract($accordion-border-radius, $accordion-border-width) !default;\n\n$accordion-body-padding-y: $accordion-padding-y !default;\n$accordion-body-padding-x: $accordion-padding-x !default;\n\n$accordion-button-padding-y: $accordion-padding-y !default;\n$accordion-button-padding-x: $accordion-padding-x !default;\n$accordion-button-color: var(--#{$prefix}body-color) !default;\n$accordion-button-bg: var(--#{$prefix}accordion-bg) !default;\n$accordion-transition: $btn-transition, border-radius .15s ease !default;\n$accordion-button-active-bg: var(--#{$prefix}primary-bg-subtle) !default;\n$accordion-button-active-color: var(--#{$prefix}primary-text-emphasis) !default;\n\n// fusv-disable\n$accordion-button-focus-border-color: $input-focus-border-color !default; // Deprecated in v5.3.3\n// fusv-enable\n$accordion-button-focus-box-shadow: $btn-focus-box-shadow !default;\n\n$accordion-icon-width: 1.25rem !default;\n$accordion-icon-color: $body-color !default;\n$accordion-icon-active-color: $primary-text-emphasis !default;\n$accordion-icon-transition: transform .2s ease-in-out !default;\n$accordion-icon-transform: rotate(-180deg) !default;\n\n$accordion-button-icon: url(\"data:image/svg+xml,\") !default;\n$accordion-button-active-icon: url(\"data:image/svg+xml,\") !default;\n// scss-docs-end accordion-variables\n\n// Tooltips\n\n// scss-docs-start tooltip-variables\n$tooltip-font-size: $font-size-sm !default;\n$tooltip-max-width: 200px !default;\n$tooltip-color: var(--#{$prefix}body-bg) !default;\n$tooltip-bg: var(--#{$prefix}emphasis-color) !default;\n$tooltip-border-radius: var(--#{$prefix}border-radius) !default;\n$tooltip-opacity: .9 !default;\n$tooltip-padding-y: $spacer * .25 !default;\n$tooltip-padding-x: $spacer * .5 !default;\n$tooltip-margin: null !default; // TODO: remove this in v6\n\n$tooltip-arrow-width: .8rem !default;\n$tooltip-arrow-height: .4rem !default;\n// fusv-disable\n$tooltip-arrow-color: null !default; // Deprecated in Bootstrap 5.2.0 for CSS variables\n// fusv-enable\n// scss-docs-end tooltip-variables\n\n// Form tooltips must come after regular tooltips\n// scss-docs-start tooltip-feedback-variables\n$form-feedback-tooltip-padding-y: $tooltip-padding-y !default;\n$form-feedback-tooltip-padding-x: $tooltip-padding-x !default;\n$form-feedback-tooltip-font-size: $tooltip-font-size !default;\n$form-feedback-tooltip-line-height: null !default;\n$form-feedback-tooltip-opacity: $tooltip-opacity !default;\n$form-feedback-tooltip-border-radius: $tooltip-border-radius !default;\n// scss-docs-end tooltip-feedback-variables\n\n\n// Popovers\n\n// scss-docs-start popover-variables\n$popover-font-size: $font-size-sm !default;\n$popover-bg: var(--#{$prefix}body-bg) !default;\n$popover-max-width: 276px !default;\n$popover-border-width: var(--#{$prefix}border-width) !default;\n$popover-border-color: var(--#{$prefix}border-color-translucent) !default;\n$popover-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$popover-inner-border-radius: calc(#{$popover-border-radius} - #{$popover-border-width}) !default; // stylelint-disable-line function-disallowed-list\n$popover-box-shadow: var(--#{$prefix}box-shadow) !default;\n\n$popover-header-font-size: $font-size-base !default;\n$popover-header-bg: var(--#{$prefix}secondary-bg) !default;\n$popover-header-color: $headings-color !default;\n$popover-header-padding-y: .5rem !default;\n$popover-header-padding-x: $spacer !default;\n\n$popover-body-color: var(--#{$prefix}body-color) !default;\n$popover-body-padding-y: $spacer !default;\n$popover-body-padding-x: $spacer !default;\n\n$popover-arrow-width: 1rem !default;\n$popover-arrow-height: .5rem !default;\n// scss-docs-end popover-variables\n\n// fusv-disable\n// Deprecated in Bootstrap 5.2.0 for CSS variables\n$popover-arrow-color: $popover-bg !default;\n$popover-arrow-outer-color: var(--#{$prefix}border-color-translucent) !default;\n// fusv-enable\n\n\n// Toasts\n\n// scss-docs-start toast-variables\n$toast-max-width: 350px !default;\n$toast-padding-x: .75rem !default;\n$toast-padding-y: .5rem !default;\n$toast-font-size: .875rem !default;\n$toast-color: null !default;\n$toast-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-border-width: var(--#{$prefix}border-width) !default;\n$toast-border-color: var(--#{$prefix}border-color-translucent) !default;\n$toast-border-radius: var(--#{$prefix}border-radius) !default;\n$toast-box-shadow: var(--#{$prefix}box-shadow) !default;\n$toast-spacing: $container-padding-x !default;\n\n$toast-header-color: var(--#{$prefix}secondary-color) !default;\n$toast-header-background-color: rgba(var(--#{$prefix}body-bg-rgb), .85) !default;\n$toast-header-border-color: $toast-border-color !default;\n// scss-docs-end toast-variables\n\n\n// Badges\n\n// scss-docs-start badge-variables\n$badge-font-size: .75em !default;\n$badge-font-weight: $font-weight-bold !default;\n$badge-color: $white !default;\n$badge-padding-y: .35em !default;\n$badge-padding-x: .65em !default;\n$badge-border-radius: var(--#{$prefix}border-radius) !default;\n// scss-docs-end badge-variables\n\n\n// Modals\n\n// scss-docs-start modal-variables\n$modal-inner-padding: $spacer !default;\n\n$modal-footer-margin-between: .5rem !default;\n\n$modal-dialog-margin: .5rem !default;\n$modal-dialog-margin-y-sm-up: 1.75rem !default;\n\n$modal-title-line-height: $line-height-base !default;\n\n$modal-content-color: null !default;\n$modal-content-bg: var(--#{$prefix}body-bg) !default;\n$modal-content-border-color: var(--#{$prefix}border-color-translucent) !default;\n$modal-content-border-width: var(--#{$prefix}border-width) !default;\n$modal-content-border-radius: var(--#{$prefix}border-radius-lg) !default;\n$modal-content-inner-border-radius: subtract($modal-content-border-radius, $modal-content-border-width) !default;\n$modal-content-box-shadow-xs: var(--#{$prefix}box-shadow-sm) !default;\n$modal-content-box-shadow-sm-up: var(--#{$prefix}box-shadow) !default;\n\n$modal-backdrop-bg: $black !default;\n$modal-backdrop-opacity: .5 !default;\n\n$modal-header-border-color: var(--#{$prefix}border-color) !default;\n$modal-header-border-width: $modal-content-border-width !default;\n$modal-header-padding-y: $modal-inner-padding !default;\n$modal-header-padding-x: $modal-inner-padding !default;\n$modal-header-padding: $modal-header-padding-y $modal-header-padding-x !default; // Keep this for backwards compatibility\n\n$modal-footer-bg: null !default;\n$modal-footer-border-color: $modal-header-border-color !default;\n$modal-footer-border-width: $modal-header-border-width !default;\n\n$modal-sm: 300px !default;\n$modal-md: 500px !default;\n$modal-lg: 800px !default;\n$modal-xl: 1140px !default;\n\n$modal-fade-transform: translate(0, -50px) !default;\n$modal-show-transform: none !default;\n$modal-transition: transform .3s ease-out !default;\n$modal-scale-transform: scale(1.02) !default;\n// scss-docs-end modal-variables\n\n\n// Alerts\n//\n// Define alert colors, border radius, and padding.\n\n// scss-docs-start alert-variables\n$alert-padding-y: $spacer !default;\n$alert-padding-x: $spacer !default;\n$alert-margin-bottom: 1rem !default;\n$alert-border-radius: var(--#{$prefix}border-radius) !default;\n$alert-link-font-weight: $font-weight-bold !default;\n$alert-border-width: var(--#{$prefix}border-width) !default;\n$alert-dismissible-padding-r: $alert-padding-x * 3 !default; // 3x covers width of x plus default padding on either side\n// scss-docs-end alert-variables\n\n// fusv-disable\n$alert-bg-scale: -80% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-border-scale: -70% !default; // Deprecated in v5.2.0, to be removed in v6\n$alert-color-scale: 40% !default; // Deprecated in v5.2.0, to be removed in v6\n// fusv-enable\n\n// Progress bars\n\n// scss-docs-start progress-variables\n$progress-height: 1rem !default;\n$progress-font-size: $font-size-base * .75 !default;\n$progress-bg: var(--#{$prefix}secondary-bg) !default;\n$progress-border-radius: var(--#{$prefix}border-radius) !default;\n$progress-box-shadow: var(--#{$prefix}box-shadow-inset) !default;\n$progress-bar-color: $white !default;\n$progress-bar-bg: $primary !default;\n$progress-bar-animation-timing: 1s linear infinite !default;\n$progress-bar-transition: width .6s ease !default;\n// scss-docs-end progress-variables\n\n\n// List group\n\n// scss-docs-start list-group-variables\n$list-group-color: var(--#{$prefix}body-color) !default;\n$list-group-bg: var(--#{$prefix}body-bg) !default;\n$list-group-border-color: var(--#{$prefix}border-color) !default;\n$list-group-border-width: var(--#{$prefix}border-width) !default;\n$list-group-border-radius: var(--#{$prefix}border-radius) !default;\n\n$list-group-item-padding-y: $spacer * .5 !default;\n$list-group-item-padding-x: $spacer !default;\n// fusv-disable\n$list-group-item-bg-scale: -80% !default; // Deprecated in v5.3.0\n$list-group-item-color-scale: 40% !default; // Deprecated in v5.3.0\n// fusv-enable\n\n$list-group-hover-bg: var(--#{$prefix}tertiary-bg) !default;\n$list-group-active-color: $component-active-color !default;\n$list-group-active-bg: $component-active-bg !default;\n$list-group-active-border-color: $list-group-active-bg !default;\n\n$list-group-disabled-color: var(--#{$prefix}secondary-color) !default;\n$list-group-disabled-bg: $list-group-bg !default;\n\n$list-group-action-color: var(--#{$prefix}secondary-color) !default;\n$list-group-action-hover-color: var(--#{$prefix}emphasis-color) !default;\n\n$list-group-action-active-color: var(--#{$prefix}body-color) !default;\n$list-group-action-active-bg: var(--#{$prefix}secondary-bg) !default;\n// scss-docs-end list-group-variables\n\n\n// Image thumbnails\n\n// scss-docs-start thumbnail-variables\n$thumbnail-padding: .25rem !default;\n$thumbnail-bg: var(--#{$prefix}body-bg) !default;\n$thumbnail-border-width: var(--#{$prefix}border-width) !default;\n$thumbnail-border-color: var(--#{$prefix}border-color) !default;\n$thumbnail-border-radius: var(--#{$prefix}border-radius) !default;\n$thumbnail-box-shadow: var(--#{$prefix}box-shadow-sm) !default;\n// scss-docs-end thumbnail-variables\n\n\n// Figures\n\n// scss-docs-start figure-variables\n$figure-caption-font-size: $small-font-size !default;\n$figure-caption-color: var(--#{$prefix}secondary-color) !default;\n// scss-docs-end figure-variables\n\n\n// Breadcrumbs\n\n// scss-docs-start breadcrumb-variables\n$breadcrumb-font-size: null !default;\n$breadcrumb-padding-y: 0 !default;\n$breadcrumb-padding-x: 0 !default;\n$breadcrumb-item-padding-x: .5rem !default;\n$breadcrumb-margin-bottom: 1rem !default;\n$breadcrumb-bg: null !default;\n$breadcrumb-divider-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-active-color: var(--#{$prefix}secondary-color) !default;\n$breadcrumb-divider: quote(\"/\") !default;\n$breadcrumb-divider-flipped: $breadcrumb-divider !default;\n$breadcrumb-border-radius: null !default;\n// scss-docs-end breadcrumb-variables\n\n// Carousel\n\n// scss-docs-start carousel-variables\n$carousel-control-color: $white !default;\n$carousel-control-width: 15% !default;\n$carousel-control-opacity: .5 !default;\n$carousel-control-hover-opacity: .9 !default;\n$carousel-control-transition: opacity .15s ease !default;\n\n$carousel-indicator-width: 30px !default;\n$carousel-indicator-height: 3px !default;\n$carousel-indicator-hit-area-height: 10px !default;\n$carousel-indicator-spacer: 3px !default;\n$carousel-indicator-opacity: .5 !default;\n$carousel-indicator-active-bg: $white !default;\n$carousel-indicator-active-opacity: 1 !default;\n$carousel-indicator-transition: opacity .6s ease !default;\n\n$carousel-caption-width: 70% !default;\n$carousel-caption-color: $white !default;\n$carousel-caption-padding-y: 1.25rem !default;\n$carousel-caption-spacer: 1.25rem !default;\n\n$carousel-control-icon-width: 2rem !default;\n\n$carousel-control-prev-icon-bg: url(\"data:image/svg+xml,\") !default;\n$carousel-control-next-icon-bg: url(\"data:image/svg+xml,\") !default;\n\n$carousel-transition-duration: .6s !default;\n$carousel-transition: transform $carousel-transition-duration ease-in-out !default; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`)\n// scss-docs-end carousel-variables\n\n// scss-docs-start carousel-dark-variables\n$carousel-dark-indicator-active-bg: $black !default;\n$carousel-dark-caption-color: $black !default;\n$carousel-dark-control-icon-filter: invert(1) grayscale(100) !default;\n// scss-docs-end carousel-dark-variables\n\n\n// Spinners\n\n// scss-docs-start spinner-variables\n$spinner-width: 2rem !default;\n$spinner-height: $spinner-width !default;\n$spinner-vertical-align: -.125em !default;\n$spinner-border-width: .25em !default;\n$spinner-animation-speed: .75s !default;\n\n$spinner-width-sm: 1rem !default;\n$spinner-height-sm: $spinner-width-sm !default;\n$spinner-border-width-sm: .2em !default;\n// scss-docs-end spinner-variables\n\n\n// Close\n\n// scss-docs-start close-variables\n$btn-close-width: 1em !default;\n$btn-close-height: $btn-close-width !default;\n$btn-close-padding-x: .25em !default;\n$btn-close-padding-y: $btn-close-padding-x !default;\n$btn-close-color: $black !default;\n$btn-close-bg: url(\"data:image/svg+xml,\") !default;\n$btn-close-focus-shadow: $focus-ring-box-shadow !default;\n$btn-close-opacity: .5 !default;\n$btn-close-hover-opacity: .75 !default;\n$btn-close-focus-opacity: 1 !default;\n$btn-close-disabled-opacity: .25 !default;\n$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%) !default;\n// scss-docs-end close-variables\n\n\n// Offcanvas\n\n// scss-docs-start offcanvas-variables\n$offcanvas-padding-y: $modal-inner-padding !default;\n$offcanvas-padding-x: $modal-inner-padding !default;\n$offcanvas-horizontal-width: 400px !default;\n$offcanvas-vertical-height: 30vh !default;\n$offcanvas-transition-duration: .3s !default;\n$offcanvas-border-color: $modal-content-border-color !default;\n$offcanvas-border-width: $modal-content-border-width !default;\n$offcanvas-title-line-height: $modal-title-line-height !default;\n$offcanvas-bg-color: var(--#{$prefix}body-bg) !default;\n$offcanvas-color: var(--#{$prefix}body-color) !default;\n$offcanvas-box-shadow: $modal-content-box-shadow-xs !default;\n$offcanvas-backdrop-bg: $modal-backdrop-bg !default;\n$offcanvas-backdrop-opacity: $modal-backdrop-opacity !default;\n// scss-docs-end offcanvas-variables\n\n// Code\n\n$code-font-size: $small-font-size !default;\n$code-color: $pink !default;\n\n$kbd-padding-y: .1875rem !default;\n$kbd-padding-x: .375rem !default;\n$kbd-font-size: $code-font-size !default;\n$kbd-color: var(--#{$prefix}body-bg) !default;\n$kbd-bg: var(--#{$prefix}body-color) !default;\n$nested-kbd-font-weight: null !default; // Deprecated in v5.2.0, removing in v6\n\n$pre-color: null !default;\n\n@import \"variables-dark\"; // TODO: can be removed safely in v6, only here to avoid breaking changes in v5.3\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css new file mode 100644 index 00000000..672cbc2e --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css @@ -0,0 +1,6 @@ +/*! + * Bootstrap Grid v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-left:calc(var(--bs-gutter-x) * .5);padding-right:calc(var(--bs-gutter-x) * .5);margin-left:auto;margin-right:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-left:calc(-.5 * var(--bs-gutter-x));margin-right:calc(-.5 * var(--bs-gutter-x))}.row>*{box-sizing:border-box;flex-shrink:0;width:100%;max-width:100%;padding-left:calc(var(--bs-gutter-x) * .5);padding-right:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-right:8.33333333%}.offset-2{margin-right:16.66666667%}.offset-3{margin-right:25%}.offset-4{margin-right:33.33333333%}.offset-5{margin-right:41.66666667%}.offset-6{margin-right:50%}.offset-7{margin-right:58.33333333%}.offset-8{margin-right:66.66666667%}.offset-9{margin-right:75%}.offset-10{margin-right:83.33333333%}.offset-11{margin-right:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-right:0}.offset-sm-1{margin-right:8.33333333%}.offset-sm-2{margin-right:16.66666667%}.offset-sm-3{margin-right:25%}.offset-sm-4{margin-right:33.33333333%}.offset-sm-5{margin-right:41.66666667%}.offset-sm-6{margin-right:50%}.offset-sm-7{margin-right:58.33333333%}.offset-sm-8{margin-right:66.66666667%}.offset-sm-9{margin-right:75%}.offset-sm-10{margin-right:83.33333333%}.offset-sm-11{margin-right:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-right:0}.offset-md-1{margin-right:8.33333333%}.offset-md-2{margin-right:16.66666667%}.offset-md-3{margin-right:25%}.offset-md-4{margin-right:33.33333333%}.offset-md-5{margin-right:41.66666667%}.offset-md-6{margin-right:50%}.offset-md-7{margin-right:58.33333333%}.offset-md-8{margin-right:66.66666667%}.offset-md-9{margin-right:75%}.offset-md-10{margin-right:83.33333333%}.offset-md-11{margin-right:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-right:0}.offset-lg-1{margin-right:8.33333333%}.offset-lg-2{margin-right:16.66666667%}.offset-lg-3{margin-right:25%}.offset-lg-4{margin-right:33.33333333%}.offset-lg-5{margin-right:41.66666667%}.offset-lg-6{margin-right:50%}.offset-lg-7{margin-right:58.33333333%}.offset-lg-8{margin-right:66.66666667%}.offset-lg-9{margin-right:75%}.offset-lg-10{margin-right:83.33333333%}.offset-lg-11{margin-right:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-right:0}.offset-xl-1{margin-right:8.33333333%}.offset-xl-2{margin-right:16.66666667%}.offset-xl-3{margin-right:25%}.offset-xl-4{margin-right:33.33333333%}.offset-xl-5{margin-right:41.66666667%}.offset-xl-6{margin-right:50%}.offset-xl-7{margin-right:58.33333333%}.offset-xl-8{margin-right:66.66666667%}.offset-xl-9{margin-right:75%}.offset-xl-10{margin-right:83.33333333%}.offset-xl-11{margin-right:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-right:0}.offset-xxl-1{margin-right:8.33333333%}.offset-xxl-2{margin-right:16.66666667%}.offset-xxl-3{margin-right:25%}.offset-xxl-4{margin-right:33.33333333%}.offset-xxl-5{margin-right:41.66666667%}.offset-xxl-6{margin-right:50%}.offset-xxl-7{margin-right:58.33333333%}.offset-xxl-8{margin-right:66.66666667%}.offset-xxl-9{margin-right:75%}.offset-xxl-10{margin-right:83.33333333%}.offset-xxl-11{margin-right:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-left:0!important;margin-right:0!important}.mx-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-3{margin-left:1rem!important;margin-right:1rem!important}.mx-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-5{margin-left:3rem!important;margin-right:3rem!important}.mx-auto{margin-left:auto!important;margin-right:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-left:0!important}.me-1{margin-left:.25rem!important}.me-2{margin-left:.5rem!important}.me-3{margin-left:1rem!important}.me-4{margin-left:1.5rem!important}.me-5{margin-left:3rem!important}.me-auto{margin-left:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-right:0!important}.ms-1{margin-right:.25rem!important}.ms-2{margin-right:.5rem!important}.ms-3{margin-right:1rem!important}.ms-4{margin-right:1.5rem!important}.ms-5{margin-right:3rem!important}.ms-auto{margin-right:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-left:0!important;padding-right:0!important}.px-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-3{padding-left:1rem!important;padding-right:1rem!important}.px-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-5{padding-left:3rem!important;padding-right:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-left:0!important}.pe-1{padding-left:.25rem!important}.pe-2{padding-left:.5rem!important}.pe-3{padding-left:1rem!important}.pe-4{padding-left:1.5rem!important}.pe-5{padding-left:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-right:0!important}.ps-1{padding-right:.25rem!important}.ps-2{padding-right:.5rem!important}.ps-3{padding-right:1rem!important}.ps-4{padding-right:1.5rem!important}.ps-5{padding-right:3rem!important}@media (min-width:576px){.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-left:0!important;margin-right:0!important}.mx-sm-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-sm-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-sm-3{margin-left:1rem!important;margin-right:1rem!important}.mx-sm-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-sm-5{margin-left:3rem!important;margin-right:3rem!important}.mx-sm-auto{margin-left:auto!important;margin-right:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-left:0!important}.me-sm-1{margin-left:.25rem!important}.me-sm-2{margin-left:.5rem!important}.me-sm-3{margin-left:1rem!important}.me-sm-4{margin-left:1.5rem!important}.me-sm-5{margin-left:3rem!important}.me-sm-auto{margin-left:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-right:0!important}.ms-sm-1{margin-right:.25rem!important}.ms-sm-2{margin-right:.5rem!important}.ms-sm-3{margin-right:1rem!important}.ms-sm-4{margin-right:1.5rem!important}.ms-sm-5{margin-right:3rem!important}.ms-sm-auto{margin-right:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-left:0!important;padding-right:0!important}.px-sm-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-sm-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-sm-3{padding-left:1rem!important;padding-right:1rem!important}.px-sm-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-sm-5{padding-left:3rem!important;padding-right:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-left:0!important}.pe-sm-1{padding-left:.25rem!important}.pe-sm-2{padding-left:.5rem!important}.pe-sm-3{padding-left:1rem!important}.pe-sm-4{padding-left:1.5rem!important}.pe-sm-5{padding-left:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-right:0!important}.ps-sm-1{padding-right:.25rem!important}.ps-sm-2{padding-right:.5rem!important}.ps-sm-3{padding-right:1rem!important}.ps-sm-4{padding-right:1.5rem!important}.ps-sm-5{padding-right:3rem!important}}@media (min-width:768px){.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-left:0!important;margin-right:0!important}.mx-md-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-md-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-md-3{margin-left:1rem!important;margin-right:1rem!important}.mx-md-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-md-5{margin-left:3rem!important;margin-right:3rem!important}.mx-md-auto{margin-left:auto!important;margin-right:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-left:0!important}.me-md-1{margin-left:.25rem!important}.me-md-2{margin-left:.5rem!important}.me-md-3{margin-left:1rem!important}.me-md-4{margin-left:1.5rem!important}.me-md-5{margin-left:3rem!important}.me-md-auto{margin-left:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-right:0!important}.ms-md-1{margin-right:.25rem!important}.ms-md-2{margin-right:.5rem!important}.ms-md-3{margin-right:1rem!important}.ms-md-4{margin-right:1.5rem!important}.ms-md-5{margin-right:3rem!important}.ms-md-auto{margin-right:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-left:0!important;padding-right:0!important}.px-md-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-md-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-md-3{padding-left:1rem!important;padding-right:1rem!important}.px-md-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-md-5{padding-left:3rem!important;padding-right:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-left:0!important}.pe-md-1{padding-left:.25rem!important}.pe-md-2{padding-left:.5rem!important}.pe-md-3{padding-left:1rem!important}.pe-md-4{padding-left:1.5rem!important}.pe-md-5{padding-left:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-right:0!important}.ps-md-1{padding-right:.25rem!important}.ps-md-2{padding-right:.5rem!important}.ps-md-3{padding-right:1rem!important}.ps-md-4{padding-right:1.5rem!important}.ps-md-5{padding-right:3rem!important}}@media (min-width:992px){.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-left:0!important;margin-right:0!important}.mx-lg-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-lg-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-lg-3{margin-left:1rem!important;margin-right:1rem!important}.mx-lg-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-lg-5{margin-left:3rem!important;margin-right:3rem!important}.mx-lg-auto{margin-left:auto!important;margin-right:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-left:0!important}.me-lg-1{margin-left:.25rem!important}.me-lg-2{margin-left:.5rem!important}.me-lg-3{margin-left:1rem!important}.me-lg-4{margin-left:1.5rem!important}.me-lg-5{margin-left:3rem!important}.me-lg-auto{margin-left:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-right:0!important}.ms-lg-1{margin-right:.25rem!important}.ms-lg-2{margin-right:.5rem!important}.ms-lg-3{margin-right:1rem!important}.ms-lg-4{margin-right:1.5rem!important}.ms-lg-5{margin-right:3rem!important}.ms-lg-auto{margin-right:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-left:0!important;padding-right:0!important}.px-lg-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-lg-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-lg-3{padding-left:1rem!important;padding-right:1rem!important}.px-lg-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-lg-5{padding-left:3rem!important;padding-right:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-left:0!important}.pe-lg-1{padding-left:.25rem!important}.pe-lg-2{padding-left:.5rem!important}.pe-lg-3{padding-left:1rem!important}.pe-lg-4{padding-left:1.5rem!important}.pe-lg-5{padding-left:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-right:0!important}.ps-lg-1{padding-right:.25rem!important}.ps-lg-2{padding-right:.5rem!important}.ps-lg-3{padding-right:1rem!important}.ps-lg-4{padding-right:1.5rem!important}.ps-lg-5{padding-right:3rem!important}}@media (min-width:1200px){.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-left:0!important;margin-right:0!important}.mx-xl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xl-auto{margin-left:auto!important;margin-right:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-left:0!important}.me-xl-1{margin-left:.25rem!important}.me-xl-2{margin-left:.5rem!important}.me-xl-3{margin-left:1rem!important}.me-xl-4{margin-left:1.5rem!important}.me-xl-5{margin-left:3rem!important}.me-xl-auto{margin-left:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-right:0!important}.ms-xl-1{margin-right:.25rem!important}.ms-xl-2{margin-right:.5rem!important}.ms-xl-3{margin-right:1rem!important}.ms-xl-4{margin-right:1.5rem!important}.ms-xl-5{margin-right:3rem!important}.ms-xl-auto{margin-right:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-left:0!important;padding-right:0!important}.px-xl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-left:0!important}.pe-xl-1{padding-left:.25rem!important}.pe-xl-2{padding-left:.5rem!important}.pe-xl-3{padding-left:1rem!important}.pe-xl-4{padding-left:1.5rem!important}.pe-xl-5{padding-left:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-right:0!important}.ps-xl-1{padding-right:.25rem!important}.ps-xl-2{padding-right:.5rem!important}.ps-xl-3{padding-right:1rem!important}.ps-xl-4{padding-right:1.5rem!important}.ps-xl-5{padding-right:3rem!important}}@media (min-width:1400px){.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-left:0!important;margin-right:0!important}.mx-xxl-1{margin-left:.25rem!important;margin-right:.25rem!important}.mx-xxl-2{margin-left:.5rem!important;margin-right:.5rem!important}.mx-xxl-3{margin-left:1rem!important;margin-right:1rem!important}.mx-xxl-4{margin-left:1.5rem!important;margin-right:1.5rem!important}.mx-xxl-5{margin-left:3rem!important;margin-right:3rem!important}.mx-xxl-auto{margin-left:auto!important;margin-right:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-left:0!important}.me-xxl-1{margin-left:.25rem!important}.me-xxl-2{margin-left:.5rem!important}.me-xxl-3{margin-left:1rem!important}.me-xxl-4{margin-left:1.5rem!important}.me-xxl-5{margin-left:3rem!important}.me-xxl-auto{margin-left:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-right:0!important}.ms-xxl-1{margin-right:.25rem!important}.ms-xxl-2{margin-right:.5rem!important}.ms-xxl-3{margin-right:1rem!important}.ms-xxl-4{margin-right:1.5rem!important}.ms-xxl-5{margin-right:3rem!important}.ms-xxl-auto{margin-right:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-left:0!important;padding-right:0!important}.px-xxl-1{padding-left:.25rem!important;padding-right:.25rem!important}.px-xxl-2{padding-left:.5rem!important;padding-right:.5rem!important}.px-xxl-3{padding-left:1rem!important;padding-right:1rem!important}.px-xxl-4{padding-left:1.5rem!important;padding-right:1.5rem!important}.px-xxl-5{padding-left:3rem!important;padding-right:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-left:0!important}.pe-xxl-1{padding-left:.25rem!important}.pe-xxl-2{padding-left:.5rem!important}.pe-xxl-3{padding-left:1rem!important}.pe-xxl-4{padding-left:1.5rem!important}.pe-xxl-5{padding-left:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-right:0!important}.ps-xxl-1{padding-right:.25rem!important}.ps-xxl-2{padding-right:.5rem!important}.ps-xxl-3{padding-right:1rem!important}.ps-xxl-4{padding-right:1.5rem!important}.ps-xxl-5{padding-right:3rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap-grid.rtl.min.css.map */ \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map new file mode 100644 index 00000000..1c926af5 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-grid.rtl.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_containers.scss","dist/css/bootstrap-grid.rtl.css","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"AACE;;;;ACKA,WCAF,iBAGA,cACA,cACA,cAHA,cADA,eCJE,cAAA,OACA,cAAA,EACA,MAAA,KACA,aAAA,8BACA,cAAA,8BACA,YAAA,KACA,aAAA,KCsDE,yBH5CE,WAAA,cACE,UAAA,OG2CJ,yBH5CE,WAAA,cAAA,cACE,UAAA,OG2CJ,yBH5CE,WAAA,cAAA,cAAA,cACE,UAAA,OG2CJ,0BH5CE,WAAA,cAAA,cAAA,cAAA,cACE,UAAA,QG2CJ,0BH5CE,WAAA,cAAA,cAAA,cAAA,cAAA,eACE,UAAA,QIhBR,MAEI,mBAAA,EAAA,mBAAA,MAAA,mBAAA,MAAA,mBAAA,MAAA,mBAAA,OAAA,oBAAA,OAKF,KCNA,cAAA,OACA,cAAA,EACA,QAAA,KACA,UAAA,KAEA,WAAA,8BACA,YAAA,+BACA,aAAA,+BDEE,OCGF,WAAA,WAIA,YAAA,EACA,MAAA,KACA,UAAA,KACA,aAAA,8BACA,cAAA,8BACA,WAAA,mBA+CI,KACE,KAAA,EAAA,EAAA,GAGF,iBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,cACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,cACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,UAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,OAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,QAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,UAxDV,aAAA,YAwDU,UAxDV,aAAA,aAwDU,UAxDV,aAAA,IAwDU,UAxDV,aAAA,aAwDU,UAxDV,aAAA,aAwDU,UAxDV,aAAA,IAwDU,UAxDV,aAAA,aAwDU,UAxDV,aAAA,aAwDU,UAxDV,aAAA,IAwDU,WAxDV,aAAA,aAwDU,WAxDV,aAAA,aAmEM,KJ6GR,MI3GU,cAAA,EAGF,KJ6GR,MI3GU,cAAA,EAPF,KJuHR,MIrHU,cAAA,QAGF,KJuHR,MIrHU,cAAA,QAPF,KJiIR,MI/HU,cAAA,OAGF,KJiIR,MI/HU,cAAA,OAPF,KJ2IR,MIzIU,cAAA,KAGF,KJ2IR,MIzIU,cAAA,KAPF,KJqJR,MInJU,cAAA,OAGF,KJqJR,MInJU,cAAA,OAPF,KJ+JR,MI7JU,cAAA,KAGF,KJ+JR,MI7JU,cAAA,KF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,aAAA,EAwDU,aAxDV,aAAA,YAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,aAmEM,QJiSN,SI/RQ,cAAA,EAGF,QJgSN,SI9RQ,cAAA,EAPF,QJySN,SIvSQ,cAAA,QAGF,QJwSN,SItSQ,cAAA,QAPF,QJiTN,SI/SQ,cAAA,OAGF,QJgTN,SI9SQ,cAAA,OAPF,QJyTN,SIvTQ,cAAA,KAGF,QJwTN,SItTQ,cAAA,KAPF,QJiUN,SI/TQ,cAAA,OAGF,QJgUN,SI9TQ,cAAA,OAPF,QJyUN,SIvUQ,cAAA,KAGF,QJwUN,SItUQ,cAAA,MF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,aAAA,EAwDU,aAxDV,aAAA,YAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,aAmEM,QJ0cN,SIxcQ,cAAA,EAGF,QJycN,SIvcQ,cAAA,EAPF,QJkdN,SIhdQ,cAAA,QAGF,QJidN,SI/cQ,cAAA,QAPF,QJ0dN,SIxdQ,cAAA,OAGF,QJydN,SIvdQ,cAAA,OAPF,QJkeN,SIheQ,cAAA,KAGF,QJieN,SI/dQ,cAAA,KAPF,QJ0eN,SIxeQ,cAAA,OAGF,QJyeN,SIveQ,cAAA,OAPF,QJkfN,SIhfQ,cAAA,KAGF,QJifN,SI/eQ,cAAA,MF1DN,yBEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,aAAA,EAwDU,aAxDV,aAAA,YAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,aAmEM,QJmnBN,SIjnBQ,cAAA,EAGF,QJknBN,SIhnBQ,cAAA,EAPF,QJ2nBN,SIznBQ,cAAA,QAGF,QJ0nBN,SIxnBQ,cAAA,QAPF,QJmoBN,SIjoBQ,cAAA,OAGF,QJkoBN,SIhoBQ,cAAA,OAPF,QJ2oBN,SIzoBQ,cAAA,KAGF,QJ0oBN,SIxoBQ,cAAA,KAPF,QJmpBN,SIjpBQ,cAAA,OAGF,QJkpBN,SIhpBQ,cAAA,OAPF,QJ2pBN,SIzpBQ,cAAA,KAGF,QJ0pBN,SIxpBQ,cAAA,MF1DN,0BEUE,QACE,KAAA,EAAA,EAAA,GAGF,oBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,iBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,aAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,UAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,aAxDV,aAAA,EAwDU,aAxDV,aAAA,YAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,aAwDU,aAxDV,aAAA,IAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,aAmEM,QJ4xBN,SI1xBQ,cAAA,EAGF,QJ2xBN,SIzxBQ,cAAA,EAPF,QJoyBN,SIlyBQ,cAAA,QAGF,QJmyBN,SIjyBQ,cAAA,QAPF,QJ4yBN,SI1yBQ,cAAA,OAGF,QJ2yBN,SIzyBQ,cAAA,OAPF,QJozBN,SIlzBQ,cAAA,KAGF,QJmzBN,SIjzBQ,cAAA,KAPF,QJ4zBN,SI1zBQ,cAAA,OAGF,QJ2zBN,SIzzBQ,cAAA,OAPF,QJo0BN,SIl0BQ,cAAA,KAGF,QJm0BN,SIj0BQ,cAAA,MF1DN,0BEUE,SACE,KAAA,EAAA,EAAA,GAGF,qBApCJ,KAAA,EAAA,EAAA,KACA,MAAA,KAcA,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,KAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,aAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,IAFF,kBACE,KAAA,EAAA,EAAA,KACA,MAAA,aA+BE,cAhDJ,KAAA,EAAA,EAAA,KACA,MAAA,KAqDQ,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,YA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,WAhEN,KAAA,EAAA,EAAA,KACA,MAAA,IA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,aA+DM,YAhEN,KAAA,EAAA,EAAA,KACA,MAAA,KAuEQ,cAxDV,aAAA,EAwDU,cAxDV,aAAA,YAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,IAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,IAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,aAwDU,cAxDV,aAAA,IAwDU,eAxDV,aAAA,aAwDU,eAxDV,aAAA,aAmEM,SJq8BN,UIn8BQ,cAAA,EAGF,SJo8BN,UIl8BQ,cAAA,EAPF,SJ68BN,UI38BQ,cAAA,QAGF,SJ48BN,UI18BQ,cAAA,QAPF,SJq9BN,UIn9BQ,cAAA,OAGF,SJo9BN,UIl9BQ,cAAA,OAPF,SJ69BN,UI39BQ,cAAA,KAGF,SJ49BN,UI19BQ,cAAA,KAPF,SJq+BN,UIn+BQ,cAAA,OAGF,SJo+BN,UIl+BQ,cAAA,OAPF,SJ6+BN,UI3+BQ,cAAA,KAGF,SJ4+BN,UI1+BQ,cAAA,MCvDF,UAOI,QAAA,iBAPJ,gBAOI,QAAA,uBAPJ,SAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,SAOI,QAAA,gBAPJ,aAOI,QAAA,oBAPJ,cAOI,QAAA,qBAPJ,QAOI,QAAA,eAPJ,eAOI,QAAA,sBAPJ,QAOI,QAAA,eAPJ,WAOI,KAAA,EAAA,EAAA,eAPJ,UAOI,eAAA,cAPJ,aAOI,eAAA,iBAPJ,kBAOI,eAAA,sBAPJ,qBAOI,eAAA,yBAPJ,aAOI,UAAA,YAPJ,aAOI,UAAA,YAPJ,eAOI,YAAA,YAPJ,eAOI,YAAA,YAPJ,WAOI,UAAA,eAPJ,aAOI,UAAA,iBAPJ,mBAOI,UAAA,uBAPJ,uBAOI,gBAAA,qBAPJ,qBAOI,gBAAA,mBAPJ,wBAOI,gBAAA,iBAPJ,yBAOI,gBAAA,wBAPJ,wBAOI,gBAAA,uBAPJ,wBAOI,gBAAA,uBAPJ,mBAOI,YAAA,qBAPJ,iBAOI,YAAA,mBAPJ,oBAOI,YAAA,iBAPJ,sBAOI,YAAA,mBAPJ,qBAOI,YAAA,kBAPJ,qBAOI,cAAA,qBAPJ,mBAOI,cAAA,mBAPJ,sBAOI,cAAA,iBAPJ,uBAOI,cAAA,wBAPJ,sBAOI,cAAA,uBAPJ,uBAOI,cAAA,kBAPJ,iBAOI,WAAA,eAPJ,kBAOI,WAAA,qBAPJ,gBAOI,WAAA,mBAPJ,mBAOI,WAAA,iBAPJ,qBAOI,WAAA,mBAPJ,oBAOI,WAAA,kBAPJ,aAOI,MAAA,aAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,SAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,KAOI,OAAA,YAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,gBAPJ,KAOI,OAAA,eAPJ,KAOI,OAAA,iBAPJ,KAOI,OAAA,eAPJ,QAOI,OAAA,eAPJ,MAOI,YAAA,YAAA,aAAA,YAPJ,MAOI,YAAA,iBAAA,aAAA,iBAPJ,MAOI,YAAA,gBAAA,aAAA,gBAPJ,MAOI,YAAA,eAAA,aAAA,eAPJ,MAOI,YAAA,iBAAA,aAAA,iBAPJ,MAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,MAOI,WAAA,YAAA,cAAA,YAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,gBAAA,cAAA,gBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,iBAAA,cAAA,iBAPJ,MAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,MAOI,WAAA,YAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,gBAPJ,MAOI,WAAA,eAPJ,MAOI,WAAA,iBAPJ,MAOI,WAAA,eAPJ,SAOI,WAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,SAOI,YAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eAPJ,SAOI,cAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,SAOI,aAAA,eAPJ,KAOI,QAAA,YAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,gBAPJ,KAOI,QAAA,eAPJ,KAOI,QAAA,iBAPJ,KAOI,QAAA,eAPJ,MAOI,aAAA,YAAA,cAAA,YAPJ,MAOI,aAAA,iBAAA,cAAA,iBAPJ,MAOI,aAAA,gBAAA,cAAA,gBAPJ,MAOI,aAAA,eAAA,cAAA,eAPJ,MAOI,aAAA,iBAAA,cAAA,iBAPJ,MAOI,aAAA,eAAA,cAAA,eAPJ,MAOI,YAAA,YAAA,eAAA,YAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,gBAAA,eAAA,gBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,iBAAA,eAAA,iBAPJ,MAOI,YAAA,eAAA,eAAA,eAPJ,MAOI,YAAA,YAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,gBAPJ,MAOI,YAAA,eAPJ,MAOI,YAAA,iBAPJ,MAOI,YAAA,eAPJ,MAOI,aAAA,YAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,gBAPJ,MAOI,aAAA,eAPJ,MAOI,aAAA,iBAPJ,MAOI,aAAA,eAPJ,MAOI,eAAA,YAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,gBAPJ,MAOI,eAAA,eAPJ,MAOI,eAAA,iBAPJ,MAOI,eAAA,eAPJ,MAOI,cAAA,YAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,gBAPJ,MAOI,cAAA,eAPJ,MAOI,cAAA,iBAPJ,MAOI,cAAA,eHVR,yBGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,YAAA,YAAA,aAAA,YAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,gBAAA,aAAA,gBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,YAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,aAAA,YAAA,cAAA,YAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,gBAAA,cAAA,gBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBHVR,yBGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,YAAA,YAAA,aAAA,YAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,gBAAA,aAAA,gBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,YAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,aAAA,YAAA,cAAA,YAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,gBAAA,cAAA,gBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBHVR,yBGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,YAAA,YAAA,aAAA,YAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,gBAAA,aAAA,gBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,YAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,aAAA,YAAA,cAAA,YAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,gBAAA,cAAA,gBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBHVR,0BGGI,aAOI,QAAA,iBAPJ,mBAOI,QAAA,uBAPJ,YAOI,QAAA,gBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,YAOI,QAAA,gBAPJ,gBAOI,QAAA,oBAPJ,iBAOI,QAAA,qBAPJ,WAOI,QAAA,eAPJ,kBAOI,QAAA,sBAPJ,WAOI,QAAA,eAPJ,cAOI,KAAA,EAAA,EAAA,eAPJ,aAOI,eAAA,cAPJ,gBAOI,eAAA,iBAPJ,qBAOI,eAAA,sBAPJ,wBAOI,eAAA,yBAPJ,gBAOI,UAAA,YAPJ,gBAOI,UAAA,YAPJ,kBAOI,YAAA,YAPJ,kBAOI,YAAA,YAPJ,cAOI,UAAA,eAPJ,gBAOI,UAAA,iBAPJ,sBAOI,UAAA,uBAPJ,0BAOI,gBAAA,qBAPJ,wBAOI,gBAAA,mBAPJ,2BAOI,gBAAA,iBAPJ,4BAOI,gBAAA,wBAPJ,2BAOI,gBAAA,uBAPJ,2BAOI,gBAAA,uBAPJ,sBAOI,YAAA,qBAPJ,oBAOI,YAAA,mBAPJ,uBAOI,YAAA,iBAPJ,yBAOI,YAAA,mBAPJ,wBAOI,YAAA,kBAPJ,wBAOI,cAAA,qBAPJ,sBAOI,cAAA,mBAPJ,yBAOI,cAAA,iBAPJ,0BAOI,cAAA,wBAPJ,yBAOI,cAAA,uBAPJ,0BAOI,cAAA,kBAPJ,oBAOI,WAAA,eAPJ,qBAOI,WAAA,qBAPJ,mBAOI,WAAA,mBAPJ,sBAOI,WAAA,iBAPJ,wBAOI,WAAA,mBAPJ,uBAOI,WAAA,kBAPJ,gBAOI,MAAA,aAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,YAOI,MAAA,YAPJ,eAOI,MAAA,YAPJ,QAOI,OAAA,YAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,gBAPJ,QAOI,OAAA,eAPJ,QAOI,OAAA,iBAPJ,QAOI,OAAA,eAPJ,WAOI,OAAA,eAPJ,SAOI,YAAA,YAAA,aAAA,YAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,gBAAA,aAAA,gBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,YAAA,iBAAA,aAAA,iBAPJ,SAOI,YAAA,eAAA,aAAA,eAPJ,YAOI,YAAA,eAAA,aAAA,eAPJ,SAOI,WAAA,YAAA,cAAA,YAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,gBAAA,cAAA,gBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,iBAAA,cAAA,iBAPJ,SAOI,WAAA,eAAA,cAAA,eAPJ,YAOI,WAAA,eAAA,cAAA,eAPJ,SAOI,WAAA,YAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,gBAPJ,SAOI,WAAA,eAPJ,SAOI,WAAA,iBAPJ,SAOI,WAAA,eAPJ,YAOI,WAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,YAOI,YAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,eAPJ,YAOI,cAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,YAOI,aAAA,eAPJ,QAOI,QAAA,YAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,gBAPJ,QAOI,QAAA,eAPJ,QAOI,QAAA,iBAPJ,QAOI,QAAA,eAPJ,SAOI,aAAA,YAAA,cAAA,YAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,gBAAA,cAAA,gBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,aAAA,iBAAA,cAAA,iBAPJ,SAOI,aAAA,eAAA,cAAA,eAPJ,SAOI,YAAA,YAAA,eAAA,YAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,gBAAA,eAAA,gBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,iBAAA,eAAA,iBAPJ,SAOI,YAAA,eAAA,eAAA,eAPJ,SAOI,YAAA,YAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,gBAPJ,SAOI,YAAA,eAPJ,SAOI,YAAA,iBAPJ,SAOI,YAAA,eAPJ,SAOI,aAAA,YAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,gBAPJ,SAOI,aAAA,eAPJ,SAOI,aAAA,iBAPJ,SAOI,aAAA,eAPJ,SAOI,eAAA,YAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,gBAPJ,SAOI,eAAA,eAPJ,SAOI,eAAA,iBAPJ,SAOI,eAAA,eAPJ,SAOI,cAAA,YAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBAPJ,SAOI,cAAA,eAPJ,SAOI,cAAA,iBAPJ,SAOI,cAAA,gBHVR,0BGGI,cAOI,QAAA,iBAPJ,oBAOI,QAAA,uBAPJ,aAOI,QAAA,gBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,aAOI,QAAA,gBAPJ,iBAOI,QAAA,oBAPJ,kBAOI,QAAA,qBAPJ,YAOI,QAAA,eAPJ,mBAOI,QAAA,sBAPJ,YAOI,QAAA,eAPJ,eAOI,KAAA,EAAA,EAAA,eAPJ,cAOI,eAAA,cAPJ,iBAOI,eAAA,iBAPJ,sBAOI,eAAA,sBAPJ,yBAOI,eAAA,yBAPJ,iBAOI,UAAA,YAPJ,iBAOI,UAAA,YAPJ,mBAOI,YAAA,YAPJ,mBAOI,YAAA,YAPJ,eAOI,UAAA,eAPJ,iBAOI,UAAA,iBAPJ,uBAOI,UAAA,uBAPJ,2BAOI,gBAAA,qBAPJ,yBAOI,gBAAA,mBAPJ,4BAOI,gBAAA,iBAPJ,6BAOI,gBAAA,wBAPJ,4BAOI,gBAAA,uBAPJ,4BAOI,gBAAA,uBAPJ,uBAOI,YAAA,qBAPJ,qBAOI,YAAA,mBAPJ,wBAOI,YAAA,iBAPJ,0BAOI,YAAA,mBAPJ,yBAOI,YAAA,kBAPJ,yBAOI,cAAA,qBAPJ,uBAOI,cAAA,mBAPJ,0BAOI,cAAA,iBAPJ,2BAOI,cAAA,wBAPJ,0BAOI,cAAA,uBAPJ,2BAOI,cAAA,kBAPJ,qBAOI,WAAA,eAPJ,sBAOI,WAAA,qBAPJ,oBAOI,WAAA,mBAPJ,uBAOI,WAAA,iBAPJ,yBAOI,WAAA,mBAPJ,wBAOI,WAAA,kBAPJ,iBAOI,MAAA,aAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,aAOI,MAAA,YAPJ,gBAOI,MAAA,YAPJ,SAOI,OAAA,YAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,gBAPJ,SAOI,OAAA,eAPJ,SAOI,OAAA,iBAPJ,SAOI,OAAA,eAPJ,YAOI,OAAA,eAPJ,UAOI,YAAA,YAAA,aAAA,YAPJ,UAOI,YAAA,iBAAA,aAAA,iBAPJ,UAOI,YAAA,gBAAA,aAAA,gBAPJ,UAOI,YAAA,eAAA,aAAA,eAPJ,UAOI,YAAA,iBAAA,aAAA,iBAPJ,UAOI,YAAA,eAAA,aAAA,eAPJ,aAOI,YAAA,eAAA,aAAA,eAPJ,UAOI,WAAA,YAAA,cAAA,YAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,gBAAA,cAAA,gBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,iBAAA,cAAA,iBAPJ,UAOI,WAAA,eAAA,cAAA,eAPJ,aAOI,WAAA,eAAA,cAAA,eAPJ,UAOI,WAAA,YAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,gBAPJ,UAOI,WAAA,eAPJ,UAOI,WAAA,iBAPJ,UAOI,WAAA,eAPJ,aAOI,WAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,aAOI,YAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,eAPJ,aAOI,cAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,aAOI,aAAA,eAPJ,SAOI,QAAA,YAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,gBAPJ,SAOI,QAAA,eAPJ,SAOI,QAAA,iBAPJ,SAOI,QAAA,eAPJ,UAOI,aAAA,YAAA,cAAA,YAPJ,UAOI,aAAA,iBAAA,cAAA,iBAPJ,UAOI,aAAA,gBAAA,cAAA,gBAPJ,UAOI,aAAA,eAAA,cAAA,eAPJ,UAOI,aAAA,iBAAA,cAAA,iBAPJ,UAOI,aAAA,eAAA,cAAA,eAPJ,UAOI,YAAA,YAAA,eAAA,YAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,gBAAA,eAAA,gBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,iBAAA,eAAA,iBAPJ,UAOI,YAAA,eAAA,eAAA,eAPJ,UAOI,YAAA,YAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,gBAPJ,UAOI,YAAA,eAPJ,UAOI,YAAA,iBAPJ,UAOI,YAAA,eAPJ,UAOI,aAAA,YAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,gBAPJ,UAOI,aAAA,eAPJ,UAOI,aAAA,iBAPJ,UAOI,aAAA,eAPJ,UAOI,eAAA,YAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,gBAPJ,UAOI,eAAA,eAPJ,UAOI,eAAA,iBAPJ,UAOI,eAAA,eAPJ,UAOI,cAAA,YAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBAPJ,UAOI,cAAA,eAPJ,UAOI,cAAA,iBAPJ,UAOI,cAAA,gBCnCZ,aD4BQ,gBAOI,QAAA,iBAPJ,sBAOI,QAAA,uBAPJ,eAOI,QAAA,gBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,eAOI,QAAA,gBAPJ,mBAOI,QAAA,oBAPJ,oBAOI,QAAA,qBAPJ,cAOI,QAAA,eAPJ,qBAOI,QAAA,sBAPJ,cAOI,QAAA","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n","// Container widths\n//\n// Set the container width, and override it for fixed navbars in media queries.\n\n@if $enable-container-classes {\n // Single container class with breakpoint max-widths\n .container,\n // 100% wide container at all breakpoints\n .container-fluid {\n @include make-container();\n }\n\n // Responsive containers that are 100% wide until a breakpoint\n @each $breakpoint, $container-max-width in $container-max-widths {\n .container-#{$breakpoint} {\n @extend .container-fluid;\n }\n\n @include media-breakpoint-up($breakpoint, $grid-breakpoints) {\n %responsive-container-#{$breakpoint} {\n max-width: $container-max-width;\n }\n\n // Extend each breakpoint which is smaller or equal to the current breakpoint\n $extend-breakpoint: true;\n\n @each $name, $width in $grid-breakpoints {\n @if ($extend-breakpoint) {\n .container#{breakpoint-infix($name, $grid-breakpoints)} {\n @extend %responsive-container-#{$breakpoint};\n }\n\n // Once the current breakpoint is reached, stop extending\n @if ($breakpoint == $name) {\n $extend-breakpoint: false;\n }\n }\n }\n }\n }\n}\n","/*!\n * Bootstrap Grid v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n.container,\n.container-fluid,\n.container-xxl,\n.container-xl,\n.container-lg,\n.container-md,\n.container-sm {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n width: 100%;\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n margin-left: auto;\n margin-right: auto;\n}\n\n@media (min-width: 576px) {\n .container-sm, .container {\n max-width: 540px;\n }\n}\n@media (min-width: 768px) {\n .container-md, .container-sm, .container {\n max-width: 720px;\n }\n}\n@media (min-width: 992px) {\n .container-lg, .container-md, .container-sm, .container {\n max-width: 960px;\n }\n}\n@media (min-width: 1200px) {\n .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1140px;\n }\n}\n@media (min-width: 1400px) {\n .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container {\n max-width: 1320px;\n }\n}\n:root {\n --bs-breakpoint-xs: 0;\n --bs-breakpoint-sm: 576px;\n --bs-breakpoint-md: 768px;\n --bs-breakpoint-lg: 992px;\n --bs-breakpoint-xl: 1200px;\n --bs-breakpoint-xxl: 1400px;\n}\n\n.row {\n --bs-gutter-x: 1.5rem;\n --bs-gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n margin-top: calc(-1 * var(--bs-gutter-y));\n margin-left: calc(-0.5 * var(--bs-gutter-x));\n margin-right: calc(-0.5 * var(--bs-gutter-x));\n}\n.row > * {\n box-sizing: border-box;\n flex-shrink: 0;\n width: 100%;\n max-width: 100%;\n padding-left: calc(var(--bs-gutter-x) * 0.5);\n padding-right: calc(var(--bs-gutter-x) * 0.5);\n margin-top: var(--bs-gutter-y);\n}\n\n.col {\n flex: 1 0 0%;\n}\n\n.row-cols-auto > * {\n flex: 0 0 auto;\n width: auto;\n}\n\n.row-cols-1 > * {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.row-cols-2 > * {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.row-cols-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.row-cols-4 > * {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.row-cols-5 > * {\n flex: 0 0 auto;\n width: 20%;\n}\n\n.row-cols-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-auto {\n flex: 0 0 auto;\n width: auto;\n}\n\n.col-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n}\n\n.col-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n}\n\n.col-3 {\n flex: 0 0 auto;\n width: 25%;\n}\n\n.col-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n}\n\n.col-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n}\n\n.col-6 {\n flex: 0 0 auto;\n width: 50%;\n}\n\n.col-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n}\n\n.col-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n}\n\n.col-9 {\n flex: 0 0 auto;\n width: 75%;\n}\n\n.col-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n}\n\n.col-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n}\n\n.col-12 {\n flex: 0 0 auto;\n width: 100%;\n}\n\n.offset-1 {\n margin-right: 8.33333333%;\n}\n\n.offset-2 {\n margin-right: 16.66666667%;\n}\n\n.offset-3 {\n margin-right: 25%;\n}\n\n.offset-4 {\n margin-right: 33.33333333%;\n}\n\n.offset-5 {\n margin-right: 41.66666667%;\n}\n\n.offset-6 {\n margin-right: 50%;\n}\n\n.offset-7 {\n margin-right: 58.33333333%;\n}\n\n.offset-8 {\n margin-right: 66.66666667%;\n}\n\n.offset-9 {\n margin-right: 75%;\n}\n\n.offset-10 {\n margin-right: 83.33333333%;\n}\n\n.offset-11 {\n margin-right: 91.66666667%;\n}\n\n.g-0,\n.gx-0 {\n --bs-gutter-x: 0;\n}\n\n.g-0,\n.gy-0 {\n --bs-gutter-y: 0;\n}\n\n.g-1,\n.gx-1 {\n --bs-gutter-x: 0.25rem;\n}\n\n.g-1,\n.gy-1 {\n --bs-gutter-y: 0.25rem;\n}\n\n.g-2,\n.gx-2 {\n --bs-gutter-x: 0.5rem;\n}\n\n.g-2,\n.gy-2 {\n --bs-gutter-y: 0.5rem;\n}\n\n.g-3,\n.gx-3 {\n --bs-gutter-x: 1rem;\n}\n\n.g-3,\n.gy-3 {\n --bs-gutter-y: 1rem;\n}\n\n.g-4,\n.gx-4 {\n --bs-gutter-x: 1.5rem;\n}\n\n.g-4,\n.gy-4 {\n --bs-gutter-y: 1.5rem;\n}\n\n.g-5,\n.gx-5 {\n --bs-gutter-x: 3rem;\n}\n\n.g-5,\n.gy-5 {\n --bs-gutter-y: 3rem;\n}\n\n@media (min-width: 576px) {\n .col-sm {\n flex: 1 0 0%;\n }\n .row-cols-sm-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-sm-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-sm-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-sm-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-sm-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-sm-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-sm-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-sm-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-sm-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-sm-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-sm-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-sm-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-sm-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-sm-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-sm-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-sm-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-sm-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-sm-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-sm-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-sm-0 {\n margin-right: 0;\n }\n .offset-sm-1 {\n margin-right: 8.33333333%;\n }\n .offset-sm-2 {\n margin-right: 16.66666667%;\n }\n .offset-sm-3 {\n margin-right: 25%;\n }\n .offset-sm-4 {\n margin-right: 33.33333333%;\n }\n .offset-sm-5 {\n margin-right: 41.66666667%;\n }\n .offset-sm-6 {\n margin-right: 50%;\n }\n .offset-sm-7 {\n margin-right: 58.33333333%;\n }\n .offset-sm-8 {\n margin-right: 66.66666667%;\n }\n .offset-sm-9 {\n margin-right: 75%;\n }\n .offset-sm-10 {\n margin-right: 83.33333333%;\n }\n .offset-sm-11 {\n margin-right: 91.66666667%;\n }\n .g-sm-0,\n .gx-sm-0 {\n --bs-gutter-x: 0;\n }\n .g-sm-0,\n .gy-sm-0 {\n --bs-gutter-y: 0;\n }\n .g-sm-1,\n .gx-sm-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-sm-1,\n .gy-sm-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-sm-2,\n .gx-sm-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-sm-2,\n .gy-sm-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-sm-3,\n .gx-sm-3 {\n --bs-gutter-x: 1rem;\n }\n .g-sm-3,\n .gy-sm-3 {\n --bs-gutter-y: 1rem;\n }\n .g-sm-4,\n .gx-sm-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-sm-4,\n .gy-sm-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-sm-5,\n .gx-sm-5 {\n --bs-gutter-x: 3rem;\n }\n .g-sm-5,\n .gy-sm-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 768px) {\n .col-md {\n flex: 1 0 0%;\n }\n .row-cols-md-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-md-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-md-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-md-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-md-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-md-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-md-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-md-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-md-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-md-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-md-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-md-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-md-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-md-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-md-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-md-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-md-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-md-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-md-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-md-0 {\n margin-right: 0;\n }\n .offset-md-1 {\n margin-right: 8.33333333%;\n }\n .offset-md-2 {\n margin-right: 16.66666667%;\n }\n .offset-md-3 {\n margin-right: 25%;\n }\n .offset-md-4 {\n margin-right: 33.33333333%;\n }\n .offset-md-5 {\n margin-right: 41.66666667%;\n }\n .offset-md-6 {\n margin-right: 50%;\n }\n .offset-md-7 {\n margin-right: 58.33333333%;\n }\n .offset-md-8 {\n margin-right: 66.66666667%;\n }\n .offset-md-9 {\n margin-right: 75%;\n }\n .offset-md-10 {\n margin-right: 83.33333333%;\n }\n .offset-md-11 {\n margin-right: 91.66666667%;\n }\n .g-md-0,\n .gx-md-0 {\n --bs-gutter-x: 0;\n }\n .g-md-0,\n .gy-md-0 {\n --bs-gutter-y: 0;\n }\n .g-md-1,\n .gx-md-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-md-1,\n .gy-md-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-md-2,\n .gx-md-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-md-2,\n .gy-md-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-md-3,\n .gx-md-3 {\n --bs-gutter-x: 1rem;\n }\n .g-md-3,\n .gy-md-3 {\n --bs-gutter-y: 1rem;\n }\n .g-md-4,\n .gx-md-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-md-4,\n .gy-md-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-md-5,\n .gx-md-5 {\n --bs-gutter-x: 3rem;\n }\n .g-md-5,\n .gy-md-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 992px) {\n .col-lg {\n flex: 1 0 0%;\n }\n .row-cols-lg-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-lg-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-lg-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-lg-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-lg-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-lg-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-lg-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-lg-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-lg-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-lg-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-lg-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-lg-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-lg-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-lg-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-lg-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-lg-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-lg-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-lg-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-lg-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-lg-0 {\n margin-right: 0;\n }\n .offset-lg-1 {\n margin-right: 8.33333333%;\n }\n .offset-lg-2 {\n margin-right: 16.66666667%;\n }\n .offset-lg-3 {\n margin-right: 25%;\n }\n .offset-lg-4 {\n margin-right: 33.33333333%;\n }\n .offset-lg-5 {\n margin-right: 41.66666667%;\n }\n .offset-lg-6 {\n margin-right: 50%;\n }\n .offset-lg-7 {\n margin-right: 58.33333333%;\n }\n .offset-lg-8 {\n margin-right: 66.66666667%;\n }\n .offset-lg-9 {\n margin-right: 75%;\n }\n .offset-lg-10 {\n margin-right: 83.33333333%;\n }\n .offset-lg-11 {\n margin-right: 91.66666667%;\n }\n .g-lg-0,\n .gx-lg-0 {\n --bs-gutter-x: 0;\n }\n .g-lg-0,\n .gy-lg-0 {\n --bs-gutter-y: 0;\n }\n .g-lg-1,\n .gx-lg-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-lg-1,\n .gy-lg-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-lg-2,\n .gx-lg-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-lg-2,\n .gy-lg-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-lg-3,\n .gx-lg-3 {\n --bs-gutter-x: 1rem;\n }\n .g-lg-3,\n .gy-lg-3 {\n --bs-gutter-y: 1rem;\n }\n .g-lg-4,\n .gx-lg-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-lg-4,\n .gy-lg-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-lg-5,\n .gx-lg-5 {\n --bs-gutter-x: 3rem;\n }\n .g-lg-5,\n .gy-lg-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1200px) {\n .col-xl {\n flex: 1 0 0%;\n }\n .row-cols-xl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xl-0 {\n margin-right: 0;\n }\n .offset-xl-1 {\n margin-right: 8.33333333%;\n }\n .offset-xl-2 {\n margin-right: 16.66666667%;\n }\n .offset-xl-3 {\n margin-right: 25%;\n }\n .offset-xl-4 {\n margin-right: 33.33333333%;\n }\n .offset-xl-5 {\n margin-right: 41.66666667%;\n }\n .offset-xl-6 {\n margin-right: 50%;\n }\n .offset-xl-7 {\n margin-right: 58.33333333%;\n }\n .offset-xl-8 {\n margin-right: 66.66666667%;\n }\n .offset-xl-9 {\n margin-right: 75%;\n }\n .offset-xl-10 {\n margin-right: 83.33333333%;\n }\n .offset-xl-11 {\n margin-right: 91.66666667%;\n }\n .g-xl-0,\n .gx-xl-0 {\n --bs-gutter-x: 0;\n }\n .g-xl-0,\n .gy-xl-0 {\n --bs-gutter-y: 0;\n }\n .g-xl-1,\n .gx-xl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xl-1,\n .gy-xl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xl-2,\n .gx-xl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xl-2,\n .gy-xl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xl-3,\n .gx-xl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xl-3,\n .gy-xl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xl-4,\n .gx-xl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xl-4,\n .gy-xl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xl-5,\n .gx-xl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xl-5,\n .gy-xl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n@media (min-width: 1400px) {\n .col-xxl {\n flex: 1 0 0%;\n }\n .row-cols-xxl-auto > * {\n flex: 0 0 auto;\n width: auto;\n }\n .row-cols-xxl-1 > * {\n flex: 0 0 auto;\n width: 100%;\n }\n .row-cols-xxl-2 > * {\n flex: 0 0 auto;\n width: 50%;\n }\n .row-cols-xxl-3 > * {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .row-cols-xxl-4 > * {\n flex: 0 0 auto;\n width: 25%;\n }\n .row-cols-xxl-5 > * {\n flex: 0 0 auto;\n width: 20%;\n }\n .row-cols-xxl-6 > * {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-auto {\n flex: 0 0 auto;\n width: auto;\n }\n .col-xxl-1 {\n flex: 0 0 auto;\n width: 8.33333333%;\n }\n .col-xxl-2 {\n flex: 0 0 auto;\n width: 16.66666667%;\n }\n .col-xxl-3 {\n flex: 0 0 auto;\n width: 25%;\n }\n .col-xxl-4 {\n flex: 0 0 auto;\n width: 33.33333333%;\n }\n .col-xxl-5 {\n flex: 0 0 auto;\n width: 41.66666667%;\n }\n .col-xxl-6 {\n flex: 0 0 auto;\n width: 50%;\n }\n .col-xxl-7 {\n flex: 0 0 auto;\n width: 58.33333333%;\n }\n .col-xxl-8 {\n flex: 0 0 auto;\n width: 66.66666667%;\n }\n .col-xxl-9 {\n flex: 0 0 auto;\n width: 75%;\n }\n .col-xxl-10 {\n flex: 0 0 auto;\n width: 83.33333333%;\n }\n .col-xxl-11 {\n flex: 0 0 auto;\n width: 91.66666667%;\n }\n .col-xxl-12 {\n flex: 0 0 auto;\n width: 100%;\n }\n .offset-xxl-0 {\n margin-right: 0;\n }\n .offset-xxl-1 {\n margin-right: 8.33333333%;\n }\n .offset-xxl-2 {\n margin-right: 16.66666667%;\n }\n .offset-xxl-3 {\n margin-right: 25%;\n }\n .offset-xxl-4 {\n margin-right: 33.33333333%;\n }\n .offset-xxl-5 {\n margin-right: 41.66666667%;\n }\n .offset-xxl-6 {\n margin-right: 50%;\n }\n .offset-xxl-7 {\n margin-right: 58.33333333%;\n }\n .offset-xxl-8 {\n margin-right: 66.66666667%;\n }\n .offset-xxl-9 {\n margin-right: 75%;\n }\n .offset-xxl-10 {\n margin-right: 83.33333333%;\n }\n .offset-xxl-11 {\n margin-right: 91.66666667%;\n }\n .g-xxl-0,\n .gx-xxl-0 {\n --bs-gutter-x: 0;\n }\n .g-xxl-0,\n .gy-xxl-0 {\n --bs-gutter-y: 0;\n }\n .g-xxl-1,\n .gx-xxl-1 {\n --bs-gutter-x: 0.25rem;\n }\n .g-xxl-1,\n .gy-xxl-1 {\n --bs-gutter-y: 0.25rem;\n }\n .g-xxl-2,\n .gx-xxl-2 {\n --bs-gutter-x: 0.5rem;\n }\n .g-xxl-2,\n .gy-xxl-2 {\n --bs-gutter-y: 0.5rem;\n }\n .g-xxl-3,\n .gx-xxl-3 {\n --bs-gutter-x: 1rem;\n }\n .g-xxl-3,\n .gy-xxl-3 {\n --bs-gutter-y: 1rem;\n }\n .g-xxl-4,\n .gx-xxl-4 {\n --bs-gutter-x: 1.5rem;\n }\n .g-xxl-4,\n .gy-xxl-4 {\n --bs-gutter-y: 1.5rem;\n }\n .g-xxl-5,\n .gx-xxl-5 {\n --bs-gutter-x: 3rem;\n }\n .g-xxl-5,\n .gy-xxl-5 {\n --bs-gutter-y: 3rem;\n }\n}\n.d-inline {\n display: inline !important;\n}\n\n.d-inline-block {\n display: inline-block !important;\n}\n\n.d-block {\n display: block !important;\n}\n\n.d-grid {\n display: grid !important;\n}\n\n.d-inline-grid {\n display: inline-grid !important;\n}\n\n.d-table {\n display: table !important;\n}\n\n.d-table-row {\n display: table-row !important;\n}\n\n.d-table-cell {\n display: table-cell !important;\n}\n\n.d-flex {\n display: flex !important;\n}\n\n.d-inline-flex {\n display: inline-flex !important;\n}\n\n.d-none {\n display: none !important;\n}\n\n.flex-fill {\n flex: 1 1 auto !important;\n}\n\n.flex-row {\n flex-direction: row !important;\n}\n\n.flex-column {\n flex-direction: column !important;\n}\n\n.flex-row-reverse {\n flex-direction: row-reverse !important;\n}\n\n.flex-column-reverse {\n flex-direction: column-reverse !important;\n}\n\n.flex-grow-0 {\n flex-grow: 0 !important;\n}\n\n.flex-grow-1 {\n flex-grow: 1 !important;\n}\n\n.flex-shrink-0 {\n flex-shrink: 0 !important;\n}\n\n.flex-shrink-1 {\n flex-shrink: 1 !important;\n}\n\n.flex-wrap {\n flex-wrap: wrap !important;\n}\n\n.flex-nowrap {\n flex-wrap: nowrap !important;\n}\n\n.flex-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n}\n\n.justify-content-start {\n justify-content: flex-start !important;\n}\n\n.justify-content-end {\n justify-content: flex-end !important;\n}\n\n.justify-content-center {\n justify-content: center !important;\n}\n\n.justify-content-between {\n justify-content: space-between !important;\n}\n\n.justify-content-around {\n justify-content: space-around !important;\n}\n\n.justify-content-evenly {\n justify-content: space-evenly !important;\n}\n\n.align-items-start {\n align-items: flex-start !important;\n}\n\n.align-items-end {\n align-items: flex-end !important;\n}\n\n.align-items-center {\n align-items: center !important;\n}\n\n.align-items-baseline {\n align-items: baseline !important;\n}\n\n.align-items-stretch {\n align-items: stretch !important;\n}\n\n.align-content-start {\n align-content: flex-start !important;\n}\n\n.align-content-end {\n align-content: flex-end !important;\n}\n\n.align-content-center {\n align-content: center !important;\n}\n\n.align-content-between {\n align-content: space-between !important;\n}\n\n.align-content-around {\n align-content: space-around !important;\n}\n\n.align-content-stretch {\n align-content: stretch !important;\n}\n\n.align-self-auto {\n align-self: auto !important;\n}\n\n.align-self-start {\n align-self: flex-start !important;\n}\n\n.align-self-end {\n align-self: flex-end !important;\n}\n\n.align-self-center {\n align-self: center !important;\n}\n\n.align-self-baseline {\n align-self: baseline !important;\n}\n\n.align-self-stretch {\n align-self: stretch !important;\n}\n\n.order-first {\n order: -1 !important;\n}\n\n.order-0 {\n order: 0 !important;\n}\n\n.order-1 {\n order: 1 !important;\n}\n\n.order-2 {\n order: 2 !important;\n}\n\n.order-3 {\n order: 3 !important;\n}\n\n.order-4 {\n order: 4 !important;\n}\n\n.order-5 {\n order: 5 !important;\n}\n\n.order-last {\n order: 6 !important;\n}\n\n.m-0 {\n margin: 0 !important;\n}\n\n.m-1 {\n margin: 0.25rem !important;\n}\n\n.m-2 {\n margin: 0.5rem !important;\n}\n\n.m-3 {\n margin: 1rem !important;\n}\n\n.m-4 {\n margin: 1.5rem !important;\n}\n\n.m-5 {\n margin: 3rem !important;\n}\n\n.m-auto {\n margin: auto !important;\n}\n\n.mx-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n}\n\n.mx-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n}\n\n.mx-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n}\n\n.mx-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n}\n\n.mx-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n}\n\n.mx-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n}\n\n.mx-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n}\n\n.my-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n}\n\n.my-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n}\n\n.my-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n}\n\n.my-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n}\n\n.my-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n}\n\n.my-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n}\n\n.my-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n}\n\n.mt-0 {\n margin-top: 0 !important;\n}\n\n.mt-1 {\n margin-top: 0.25rem !important;\n}\n\n.mt-2 {\n margin-top: 0.5rem !important;\n}\n\n.mt-3 {\n margin-top: 1rem !important;\n}\n\n.mt-4 {\n margin-top: 1.5rem !important;\n}\n\n.mt-5 {\n margin-top: 3rem !important;\n}\n\n.mt-auto {\n margin-top: auto !important;\n}\n\n.me-0 {\n margin-left: 0 !important;\n}\n\n.me-1 {\n margin-left: 0.25rem !important;\n}\n\n.me-2 {\n margin-left: 0.5rem !important;\n}\n\n.me-3 {\n margin-left: 1rem !important;\n}\n\n.me-4 {\n margin-left: 1.5rem !important;\n}\n\n.me-5 {\n margin-left: 3rem !important;\n}\n\n.me-auto {\n margin-left: auto !important;\n}\n\n.mb-0 {\n margin-bottom: 0 !important;\n}\n\n.mb-1 {\n margin-bottom: 0.25rem !important;\n}\n\n.mb-2 {\n margin-bottom: 0.5rem !important;\n}\n\n.mb-3 {\n margin-bottom: 1rem !important;\n}\n\n.mb-4 {\n margin-bottom: 1.5rem !important;\n}\n\n.mb-5 {\n margin-bottom: 3rem !important;\n}\n\n.mb-auto {\n margin-bottom: auto !important;\n}\n\n.ms-0 {\n margin-right: 0 !important;\n}\n\n.ms-1 {\n margin-right: 0.25rem !important;\n}\n\n.ms-2 {\n margin-right: 0.5rem !important;\n}\n\n.ms-3 {\n margin-right: 1rem !important;\n}\n\n.ms-4 {\n margin-right: 1.5rem !important;\n}\n\n.ms-5 {\n margin-right: 3rem !important;\n}\n\n.ms-auto {\n margin-right: auto !important;\n}\n\n.p-0 {\n padding: 0 !important;\n}\n\n.p-1 {\n padding: 0.25rem !important;\n}\n\n.p-2 {\n padding: 0.5rem !important;\n}\n\n.p-3 {\n padding: 1rem !important;\n}\n\n.p-4 {\n padding: 1.5rem !important;\n}\n\n.p-5 {\n padding: 3rem !important;\n}\n\n.px-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n}\n\n.px-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n}\n\n.px-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n}\n\n.px-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n}\n\n.px-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n}\n\n.px-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n}\n\n.py-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n}\n\n.py-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n}\n\n.py-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n}\n\n.py-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n}\n\n.py-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n}\n\n.py-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n}\n\n.pt-0 {\n padding-top: 0 !important;\n}\n\n.pt-1 {\n padding-top: 0.25rem !important;\n}\n\n.pt-2 {\n padding-top: 0.5rem !important;\n}\n\n.pt-3 {\n padding-top: 1rem !important;\n}\n\n.pt-4 {\n padding-top: 1.5rem !important;\n}\n\n.pt-5 {\n padding-top: 3rem !important;\n}\n\n.pe-0 {\n padding-left: 0 !important;\n}\n\n.pe-1 {\n padding-left: 0.25rem !important;\n}\n\n.pe-2 {\n padding-left: 0.5rem !important;\n}\n\n.pe-3 {\n padding-left: 1rem !important;\n}\n\n.pe-4 {\n padding-left: 1.5rem !important;\n}\n\n.pe-5 {\n padding-left: 3rem !important;\n}\n\n.pb-0 {\n padding-bottom: 0 !important;\n}\n\n.pb-1 {\n padding-bottom: 0.25rem !important;\n}\n\n.pb-2 {\n padding-bottom: 0.5rem !important;\n}\n\n.pb-3 {\n padding-bottom: 1rem !important;\n}\n\n.pb-4 {\n padding-bottom: 1.5rem !important;\n}\n\n.pb-5 {\n padding-bottom: 3rem !important;\n}\n\n.ps-0 {\n padding-right: 0 !important;\n}\n\n.ps-1 {\n padding-right: 0.25rem !important;\n}\n\n.ps-2 {\n padding-right: 0.5rem !important;\n}\n\n.ps-3 {\n padding-right: 1rem !important;\n}\n\n.ps-4 {\n padding-right: 1.5rem !important;\n}\n\n.ps-5 {\n padding-right: 3rem !important;\n}\n\n@media (min-width: 576px) {\n .d-sm-inline {\n display: inline !important;\n }\n .d-sm-inline-block {\n display: inline-block !important;\n }\n .d-sm-block {\n display: block !important;\n }\n .d-sm-grid {\n display: grid !important;\n }\n .d-sm-inline-grid {\n display: inline-grid !important;\n }\n .d-sm-table {\n display: table !important;\n }\n .d-sm-table-row {\n display: table-row !important;\n }\n .d-sm-table-cell {\n display: table-cell !important;\n }\n .d-sm-flex {\n display: flex !important;\n }\n .d-sm-inline-flex {\n display: inline-flex !important;\n }\n .d-sm-none {\n display: none !important;\n }\n .flex-sm-fill {\n flex: 1 1 auto !important;\n }\n .flex-sm-row {\n flex-direction: row !important;\n }\n .flex-sm-column {\n flex-direction: column !important;\n }\n .flex-sm-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-sm-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-sm-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-sm-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-sm-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-sm-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-sm-wrap {\n flex-wrap: wrap !important;\n }\n .flex-sm-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-sm-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-sm-start {\n justify-content: flex-start !important;\n }\n .justify-content-sm-end {\n justify-content: flex-end !important;\n }\n .justify-content-sm-center {\n justify-content: center !important;\n }\n .justify-content-sm-between {\n justify-content: space-between !important;\n }\n .justify-content-sm-around {\n justify-content: space-around !important;\n }\n .justify-content-sm-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-sm-start {\n align-items: flex-start !important;\n }\n .align-items-sm-end {\n align-items: flex-end !important;\n }\n .align-items-sm-center {\n align-items: center !important;\n }\n .align-items-sm-baseline {\n align-items: baseline !important;\n }\n .align-items-sm-stretch {\n align-items: stretch !important;\n }\n .align-content-sm-start {\n align-content: flex-start !important;\n }\n .align-content-sm-end {\n align-content: flex-end !important;\n }\n .align-content-sm-center {\n align-content: center !important;\n }\n .align-content-sm-between {\n align-content: space-between !important;\n }\n .align-content-sm-around {\n align-content: space-around !important;\n }\n .align-content-sm-stretch {\n align-content: stretch !important;\n }\n .align-self-sm-auto {\n align-self: auto !important;\n }\n .align-self-sm-start {\n align-self: flex-start !important;\n }\n .align-self-sm-end {\n align-self: flex-end !important;\n }\n .align-self-sm-center {\n align-self: center !important;\n }\n .align-self-sm-baseline {\n align-self: baseline !important;\n }\n .align-self-sm-stretch {\n align-self: stretch !important;\n }\n .order-sm-first {\n order: -1 !important;\n }\n .order-sm-0 {\n order: 0 !important;\n }\n .order-sm-1 {\n order: 1 !important;\n }\n .order-sm-2 {\n order: 2 !important;\n }\n .order-sm-3 {\n order: 3 !important;\n }\n .order-sm-4 {\n order: 4 !important;\n }\n .order-sm-5 {\n order: 5 !important;\n }\n .order-sm-last {\n order: 6 !important;\n }\n .m-sm-0 {\n margin: 0 !important;\n }\n .m-sm-1 {\n margin: 0.25rem !important;\n }\n .m-sm-2 {\n margin: 0.5rem !important;\n }\n .m-sm-3 {\n margin: 1rem !important;\n }\n .m-sm-4 {\n margin: 1.5rem !important;\n }\n .m-sm-5 {\n margin: 3rem !important;\n }\n .m-sm-auto {\n margin: auto !important;\n }\n .mx-sm-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-sm-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-sm-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-sm-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-sm-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-sm-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-sm-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-sm-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-sm-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-sm-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-sm-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-sm-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-sm-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-sm-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-sm-0 {\n margin-top: 0 !important;\n }\n .mt-sm-1 {\n margin-top: 0.25rem !important;\n }\n .mt-sm-2 {\n margin-top: 0.5rem !important;\n }\n .mt-sm-3 {\n margin-top: 1rem !important;\n }\n .mt-sm-4 {\n margin-top: 1.5rem !important;\n }\n .mt-sm-5 {\n margin-top: 3rem !important;\n }\n .mt-sm-auto {\n margin-top: auto !important;\n }\n .me-sm-0 {\n margin-left: 0 !important;\n }\n .me-sm-1 {\n margin-left: 0.25rem !important;\n }\n .me-sm-2 {\n margin-left: 0.5rem !important;\n }\n .me-sm-3 {\n margin-left: 1rem !important;\n }\n .me-sm-4 {\n margin-left: 1.5rem !important;\n }\n .me-sm-5 {\n margin-left: 3rem !important;\n }\n .me-sm-auto {\n margin-left: auto !important;\n }\n .mb-sm-0 {\n margin-bottom: 0 !important;\n }\n .mb-sm-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-sm-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-sm-3 {\n margin-bottom: 1rem !important;\n }\n .mb-sm-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-sm-5 {\n margin-bottom: 3rem !important;\n }\n .mb-sm-auto {\n margin-bottom: auto !important;\n }\n .ms-sm-0 {\n margin-right: 0 !important;\n }\n .ms-sm-1 {\n margin-right: 0.25rem !important;\n }\n .ms-sm-2 {\n margin-right: 0.5rem !important;\n }\n .ms-sm-3 {\n margin-right: 1rem !important;\n }\n .ms-sm-4 {\n margin-right: 1.5rem !important;\n }\n .ms-sm-5 {\n margin-right: 3rem !important;\n }\n .ms-sm-auto {\n margin-right: auto !important;\n }\n .p-sm-0 {\n padding: 0 !important;\n }\n .p-sm-1 {\n padding: 0.25rem !important;\n }\n .p-sm-2 {\n padding: 0.5rem !important;\n }\n .p-sm-3 {\n padding: 1rem !important;\n }\n .p-sm-4 {\n padding: 1.5rem !important;\n }\n .p-sm-5 {\n padding: 3rem !important;\n }\n .px-sm-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-sm-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-sm-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-sm-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-sm-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-sm-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-sm-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-sm-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-sm-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-sm-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-sm-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-sm-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-sm-0 {\n padding-top: 0 !important;\n }\n .pt-sm-1 {\n padding-top: 0.25rem !important;\n }\n .pt-sm-2 {\n padding-top: 0.5rem !important;\n }\n .pt-sm-3 {\n padding-top: 1rem !important;\n }\n .pt-sm-4 {\n padding-top: 1.5rem !important;\n }\n .pt-sm-5 {\n padding-top: 3rem !important;\n }\n .pe-sm-0 {\n padding-left: 0 !important;\n }\n .pe-sm-1 {\n padding-left: 0.25rem !important;\n }\n .pe-sm-2 {\n padding-left: 0.5rem !important;\n }\n .pe-sm-3 {\n padding-left: 1rem !important;\n }\n .pe-sm-4 {\n padding-left: 1.5rem !important;\n }\n .pe-sm-5 {\n padding-left: 3rem !important;\n }\n .pb-sm-0 {\n padding-bottom: 0 !important;\n }\n .pb-sm-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-sm-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-sm-3 {\n padding-bottom: 1rem !important;\n }\n .pb-sm-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-sm-5 {\n padding-bottom: 3rem !important;\n }\n .ps-sm-0 {\n padding-right: 0 !important;\n }\n .ps-sm-1 {\n padding-right: 0.25rem !important;\n }\n .ps-sm-2 {\n padding-right: 0.5rem !important;\n }\n .ps-sm-3 {\n padding-right: 1rem !important;\n }\n .ps-sm-4 {\n padding-right: 1.5rem !important;\n }\n .ps-sm-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 768px) {\n .d-md-inline {\n display: inline !important;\n }\n .d-md-inline-block {\n display: inline-block !important;\n }\n .d-md-block {\n display: block !important;\n }\n .d-md-grid {\n display: grid !important;\n }\n .d-md-inline-grid {\n display: inline-grid !important;\n }\n .d-md-table {\n display: table !important;\n }\n .d-md-table-row {\n display: table-row !important;\n }\n .d-md-table-cell {\n display: table-cell !important;\n }\n .d-md-flex {\n display: flex !important;\n }\n .d-md-inline-flex {\n display: inline-flex !important;\n }\n .d-md-none {\n display: none !important;\n }\n .flex-md-fill {\n flex: 1 1 auto !important;\n }\n .flex-md-row {\n flex-direction: row !important;\n }\n .flex-md-column {\n flex-direction: column !important;\n }\n .flex-md-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-md-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-md-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-md-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-md-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-md-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-md-wrap {\n flex-wrap: wrap !important;\n }\n .flex-md-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-md-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-md-start {\n justify-content: flex-start !important;\n }\n .justify-content-md-end {\n justify-content: flex-end !important;\n }\n .justify-content-md-center {\n justify-content: center !important;\n }\n .justify-content-md-between {\n justify-content: space-between !important;\n }\n .justify-content-md-around {\n justify-content: space-around !important;\n }\n .justify-content-md-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-md-start {\n align-items: flex-start !important;\n }\n .align-items-md-end {\n align-items: flex-end !important;\n }\n .align-items-md-center {\n align-items: center !important;\n }\n .align-items-md-baseline {\n align-items: baseline !important;\n }\n .align-items-md-stretch {\n align-items: stretch !important;\n }\n .align-content-md-start {\n align-content: flex-start !important;\n }\n .align-content-md-end {\n align-content: flex-end !important;\n }\n .align-content-md-center {\n align-content: center !important;\n }\n .align-content-md-between {\n align-content: space-between !important;\n }\n .align-content-md-around {\n align-content: space-around !important;\n }\n .align-content-md-stretch {\n align-content: stretch !important;\n }\n .align-self-md-auto {\n align-self: auto !important;\n }\n .align-self-md-start {\n align-self: flex-start !important;\n }\n .align-self-md-end {\n align-self: flex-end !important;\n }\n .align-self-md-center {\n align-self: center !important;\n }\n .align-self-md-baseline {\n align-self: baseline !important;\n }\n .align-self-md-stretch {\n align-self: stretch !important;\n }\n .order-md-first {\n order: -1 !important;\n }\n .order-md-0 {\n order: 0 !important;\n }\n .order-md-1 {\n order: 1 !important;\n }\n .order-md-2 {\n order: 2 !important;\n }\n .order-md-3 {\n order: 3 !important;\n }\n .order-md-4 {\n order: 4 !important;\n }\n .order-md-5 {\n order: 5 !important;\n }\n .order-md-last {\n order: 6 !important;\n }\n .m-md-0 {\n margin: 0 !important;\n }\n .m-md-1 {\n margin: 0.25rem !important;\n }\n .m-md-2 {\n margin: 0.5rem !important;\n }\n .m-md-3 {\n margin: 1rem !important;\n }\n .m-md-4 {\n margin: 1.5rem !important;\n }\n .m-md-5 {\n margin: 3rem !important;\n }\n .m-md-auto {\n margin: auto !important;\n }\n .mx-md-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-md-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-md-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-md-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-md-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-md-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-md-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-md-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-md-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-md-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-md-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-md-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-md-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-md-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-md-0 {\n margin-top: 0 !important;\n }\n .mt-md-1 {\n margin-top: 0.25rem !important;\n }\n .mt-md-2 {\n margin-top: 0.5rem !important;\n }\n .mt-md-3 {\n margin-top: 1rem !important;\n }\n .mt-md-4 {\n margin-top: 1.5rem !important;\n }\n .mt-md-5 {\n margin-top: 3rem !important;\n }\n .mt-md-auto {\n margin-top: auto !important;\n }\n .me-md-0 {\n margin-left: 0 !important;\n }\n .me-md-1 {\n margin-left: 0.25rem !important;\n }\n .me-md-2 {\n margin-left: 0.5rem !important;\n }\n .me-md-3 {\n margin-left: 1rem !important;\n }\n .me-md-4 {\n margin-left: 1.5rem !important;\n }\n .me-md-5 {\n margin-left: 3rem !important;\n }\n .me-md-auto {\n margin-left: auto !important;\n }\n .mb-md-0 {\n margin-bottom: 0 !important;\n }\n .mb-md-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-md-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-md-3 {\n margin-bottom: 1rem !important;\n }\n .mb-md-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-md-5 {\n margin-bottom: 3rem !important;\n }\n .mb-md-auto {\n margin-bottom: auto !important;\n }\n .ms-md-0 {\n margin-right: 0 !important;\n }\n .ms-md-1 {\n margin-right: 0.25rem !important;\n }\n .ms-md-2 {\n margin-right: 0.5rem !important;\n }\n .ms-md-3 {\n margin-right: 1rem !important;\n }\n .ms-md-4 {\n margin-right: 1.5rem !important;\n }\n .ms-md-5 {\n margin-right: 3rem !important;\n }\n .ms-md-auto {\n margin-right: auto !important;\n }\n .p-md-0 {\n padding: 0 !important;\n }\n .p-md-1 {\n padding: 0.25rem !important;\n }\n .p-md-2 {\n padding: 0.5rem !important;\n }\n .p-md-3 {\n padding: 1rem !important;\n }\n .p-md-4 {\n padding: 1.5rem !important;\n }\n .p-md-5 {\n padding: 3rem !important;\n }\n .px-md-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-md-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-md-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-md-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-md-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-md-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-md-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-md-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-md-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-md-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-md-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-md-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-md-0 {\n padding-top: 0 !important;\n }\n .pt-md-1 {\n padding-top: 0.25rem !important;\n }\n .pt-md-2 {\n padding-top: 0.5rem !important;\n }\n .pt-md-3 {\n padding-top: 1rem !important;\n }\n .pt-md-4 {\n padding-top: 1.5rem !important;\n }\n .pt-md-5 {\n padding-top: 3rem !important;\n }\n .pe-md-0 {\n padding-left: 0 !important;\n }\n .pe-md-1 {\n padding-left: 0.25rem !important;\n }\n .pe-md-2 {\n padding-left: 0.5rem !important;\n }\n .pe-md-3 {\n padding-left: 1rem !important;\n }\n .pe-md-4 {\n padding-left: 1.5rem !important;\n }\n .pe-md-5 {\n padding-left: 3rem !important;\n }\n .pb-md-0 {\n padding-bottom: 0 !important;\n }\n .pb-md-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-md-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-md-3 {\n padding-bottom: 1rem !important;\n }\n .pb-md-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-md-5 {\n padding-bottom: 3rem !important;\n }\n .ps-md-0 {\n padding-right: 0 !important;\n }\n .ps-md-1 {\n padding-right: 0.25rem !important;\n }\n .ps-md-2 {\n padding-right: 0.5rem !important;\n }\n .ps-md-3 {\n padding-right: 1rem !important;\n }\n .ps-md-4 {\n padding-right: 1.5rem !important;\n }\n .ps-md-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 992px) {\n .d-lg-inline {\n display: inline !important;\n }\n .d-lg-inline-block {\n display: inline-block !important;\n }\n .d-lg-block {\n display: block !important;\n }\n .d-lg-grid {\n display: grid !important;\n }\n .d-lg-inline-grid {\n display: inline-grid !important;\n }\n .d-lg-table {\n display: table !important;\n }\n .d-lg-table-row {\n display: table-row !important;\n }\n .d-lg-table-cell {\n display: table-cell !important;\n }\n .d-lg-flex {\n display: flex !important;\n }\n .d-lg-inline-flex {\n display: inline-flex !important;\n }\n .d-lg-none {\n display: none !important;\n }\n .flex-lg-fill {\n flex: 1 1 auto !important;\n }\n .flex-lg-row {\n flex-direction: row !important;\n }\n .flex-lg-column {\n flex-direction: column !important;\n }\n .flex-lg-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-lg-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-lg-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-lg-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-lg-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-lg-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-lg-wrap {\n flex-wrap: wrap !important;\n }\n .flex-lg-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-lg-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-lg-start {\n justify-content: flex-start !important;\n }\n .justify-content-lg-end {\n justify-content: flex-end !important;\n }\n .justify-content-lg-center {\n justify-content: center !important;\n }\n .justify-content-lg-between {\n justify-content: space-between !important;\n }\n .justify-content-lg-around {\n justify-content: space-around !important;\n }\n .justify-content-lg-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-lg-start {\n align-items: flex-start !important;\n }\n .align-items-lg-end {\n align-items: flex-end !important;\n }\n .align-items-lg-center {\n align-items: center !important;\n }\n .align-items-lg-baseline {\n align-items: baseline !important;\n }\n .align-items-lg-stretch {\n align-items: stretch !important;\n }\n .align-content-lg-start {\n align-content: flex-start !important;\n }\n .align-content-lg-end {\n align-content: flex-end !important;\n }\n .align-content-lg-center {\n align-content: center !important;\n }\n .align-content-lg-between {\n align-content: space-between !important;\n }\n .align-content-lg-around {\n align-content: space-around !important;\n }\n .align-content-lg-stretch {\n align-content: stretch !important;\n }\n .align-self-lg-auto {\n align-self: auto !important;\n }\n .align-self-lg-start {\n align-self: flex-start !important;\n }\n .align-self-lg-end {\n align-self: flex-end !important;\n }\n .align-self-lg-center {\n align-self: center !important;\n }\n .align-self-lg-baseline {\n align-self: baseline !important;\n }\n .align-self-lg-stretch {\n align-self: stretch !important;\n }\n .order-lg-first {\n order: -1 !important;\n }\n .order-lg-0 {\n order: 0 !important;\n }\n .order-lg-1 {\n order: 1 !important;\n }\n .order-lg-2 {\n order: 2 !important;\n }\n .order-lg-3 {\n order: 3 !important;\n }\n .order-lg-4 {\n order: 4 !important;\n }\n .order-lg-5 {\n order: 5 !important;\n }\n .order-lg-last {\n order: 6 !important;\n }\n .m-lg-0 {\n margin: 0 !important;\n }\n .m-lg-1 {\n margin: 0.25rem !important;\n }\n .m-lg-2 {\n margin: 0.5rem !important;\n }\n .m-lg-3 {\n margin: 1rem !important;\n }\n .m-lg-4 {\n margin: 1.5rem !important;\n }\n .m-lg-5 {\n margin: 3rem !important;\n }\n .m-lg-auto {\n margin: auto !important;\n }\n .mx-lg-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-lg-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-lg-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-lg-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-lg-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-lg-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-lg-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-lg-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-lg-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-lg-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-lg-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-lg-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-lg-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-lg-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-lg-0 {\n margin-top: 0 !important;\n }\n .mt-lg-1 {\n margin-top: 0.25rem !important;\n }\n .mt-lg-2 {\n margin-top: 0.5rem !important;\n }\n .mt-lg-3 {\n margin-top: 1rem !important;\n }\n .mt-lg-4 {\n margin-top: 1.5rem !important;\n }\n .mt-lg-5 {\n margin-top: 3rem !important;\n }\n .mt-lg-auto {\n margin-top: auto !important;\n }\n .me-lg-0 {\n margin-left: 0 !important;\n }\n .me-lg-1 {\n margin-left: 0.25rem !important;\n }\n .me-lg-2 {\n margin-left: 0.5rem !important;\n }\n .me-lg-3 {\n margin-left: 1rem !important;\n }\n .me-lg-4 {\n margin-left: 1.5rem !important;\n }\n .me-lg-5 {\n margin-left: 3rem !important;\n }\n .me-lg-auto {\n margin-left: auto !important;\n }\n .mb-lg-0 {\n margin-bottom: 0 !important;\n }\n .mb-lg-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-lg-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-lg-3 {\n margin-bottom: 1rem !important;\n }\n .mb-lg-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-lg-5 {\n margin-bottom: 3rem !important;\n }\n .mb-lg-auto {\n margin-bottom: auto !important;\n }\n .ms-lg-0 {\n margin-right: 0 !important;\n }\n .ms-lg-1 {\n margin-right: 0.25rem !important;\n }\n .ms-lg-2 {\n margin-right: 0.5rem !important;\n }\n .ms-lg-3 {\n margin-right: 1rem !important;\n }\n .ms-lg-4 {\n margin-right: 1.5rem !important;\n }\n .ms-lg-5 {\n margin-right: 3rem !important;\n }\n .ms-lg-auto {\n margin-right: auto !important;\n }\n .p-lg-0 {\n padding: 0 !important;\n }\n .p-lg-1 {\n padding: 0.25rem !important;\n }\n .p-lg-2 {\n padding: 0.5rem !important;\n }\n .p-lg-3 {\n padding: 1rem !important;\n }\n .p-lg-4 {\n padding: 1.5rem !important;\n }\n .p-lg-5 {\n padding: 3rem !important;\n }\n .px-lg-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-lg-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-lg-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-lg-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-lg-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-lg-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-lg-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-lg-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-lg-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-lg-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-lg-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-lg-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-lg-0 {\n padding-top: 0 !important;\n }\n .pt-lg-1 {\n padding-top: 0.25rem !important;\n }\n .pt-lg-2 {\n padding-top: 0.5rem !important;\n }\n .pt-lg-3 {\n padding-top: 1rem !important;\n }\n .pt-lg-4 {\n padding-top: 1.5rem !important;\n }\n .pt-lg-5 {\n padding-top: 3rem !important;\n }\n .pe-lg-0 {\n padding-left: 0 !important;\n }\n .pe-lg-1 {\n padding-left: 0.25rem !important;\n }\n .pe-lg-2 {\n padding-left: 0.5rem !important;\n }\n .pe-lg-3 {\n padding-left: 1rem !important;\n }\n .pe-lg-4 {\n padding-left: 1.5rem !important;\n }\n .pe-lg-5 {\n padding-left: 3rem !important;\n }\n .pb-lg-0 {\n padding-bottom: 0 !important;\n }\n .pb-lg-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-lg-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-lg-3 {\n padding-bottom: 1rem !important;\n }\n .pb-lg-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-lg-5 {\n padding-bottom: 3rem !important;\n }\n .ps-lg-0 {\n padding-right: 0 !important;\n }\n .ps-lg-1 {\n padding-right: 0.25rem !important;\n }\n .ps-lg-2 {\n padding-right: 0.5rem !important;\n }\n .ps-lg-3 {\n padding-right: 1rem !important;\n }\n .ps-lg-4 {\n padding-right: 1.5rem !important;\n }\n .ps-lg-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 1200px) {\n .d-xl-inline {\n display: inline !important;\n }\n .d-xl-inline-block {\n display: inline-block !important;\n }\n .d-xl-block {\n display: block !important;\n }\n .d-xl-grid {\n display: grid !important;\n }\n .d-xl-inline-grid {\n display: inline-grid !important;\n }\n .d-xl-table {\n display: table !important;\n }\n .d-xl-table-row {\n display: table-row !important;\n }\n .d-xl-table-cell {\n display: table-cell !important;\n }\n .d-xl-flex {\n display: flex !important;\n }\n .d-xl-inline-flex {\n display: inline-flex !important;\n }\n .d-xl-none {\n display: none !important;\n }\n .flex-xl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xl-row {\n flex-direction: row !important;\n }\n .flex-xl-column {\n flex-direction: column !important;\n }\n .flex-xl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xl-center {\n justify-content: center !important;\n }\n .justify-content-xl-between {\n justify-content: space-between !important;\n }\n .justify-content-xl-around {\n justify-content: space-around !important;\n }\n .justify-content-xl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xl-start {\n align-items: flex-start !important;\n }\n .align-items-xl-end {\n align-items: flex-end !important;\n }\n .align-items-xl-center {\n align-items: center !important;\n }\n .align-items-xl-baseline {\n align-items: baseline !important;\n }\n .align-items-xl-stretch {\n align-items: stretch !important;\n }\n .align-content-xl-start {\n align-content: flex-start !important;\n }\n .align-content-xl-end {\n align-content: flex-end !important;\n }\n .align-content-xl-center {\n align-content: center !important;\n }\n .align-content-xl-between {\n align-content: space-between !important;\n }\n .align-content-xl-around {\n align-content: space-around !important;\n }\n .align-content-xl-stretch {\n align-content: stretch !important;\n }\n .align-self-xl-auto {\n align-self: auto !important;\n }\n .align-self-xl-start {\n align-self: flex-start !important;\n }\n .align-self-xl-end {\n align-self: flex-end !important;\n }\n .align-self-xl-center {\n align-self: center !important;\n }\n .align-self-xl-baseline {\n align-self: baseline !important;\n }\n .align-self-xl-stretch {\n align-self: stretch !important;\n }\n .order-xl-first {\n order: -1 !important;\n }\n .order-xl-0 {\n order: 0 !important;\n }\n .order-xl-1 {\n order: 1 !important;\n }\n .order-xl-2 {\n order: 2 !important;\n }\n .order-xl-3 {\n order: 3 !important;\n }\n .order-xl-4 {\n order: 4 !important;\n }\n .order-xl-5 {\n order: 5 !important;\n }\n .order-xl-last {\n order: 6 !important;\n }\n .m-xl-0 {\n margin: 0 !important;\n }\n .m-xl-1 {\n margin: 0.25rem !important;\n }\n .m-xl-2 {\n margin: 0.5rem !important;\n }\n .m-xl-3 {\n margin: 1rem !important;\n }\n .m-xl-4 {\n margin: 1.5rem !important;\n }\n .m-xl-5 {\n margin: 3rem !important;\n }\n .m-xl-auto {\n margin: auto !important;\n }\n .mx-xl-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-xl-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-xl-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-xl-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-xl-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-xl-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-xl-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-xl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xl-0 {\n margin-top: 0 !important;\n }\n .mt-xl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xl-3 {\n margin-top: 1rem !important;\n }\n .mt-xl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xl-5 {\n margin-top: 3rem !important;\n }\n .mt-xl-auto {\n margin-top: auto !important;\n }\n .me-xl-0 {\n margin-left: 0 !important;\n }\n .me-xl-1 {\n margin-left: 0.25rem !important;\n }\n .me-xl-2 {\n margin-left: 0.5rem !important;\n }\n .me-xl-3 {\n margin-left: 1rem !important;\n }\n .me-xl-4 {\n margin-left: 1.5rem !important;\n }\n .me-xl-5 {\n margin-left: 3rem !important;\n }\n .me-xl-auto {\n margin-left: auto !important;\n }\n .mb-xl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xl-auto {\n margin-bottom: auto !important;\n }\n .ms-xl-0 {\n margin-right: 0 !important;\n }\n .ms-xl-1 {\n margin-right: 0.25rem !important;\n }\n .ms-xl-2 {\n margin-right: 0.5rem !important;\n }\n .ms-xl-3 {\n margin-right: 1rem !important;\n }\n .ms-xl-4 {\n margin-right: 1.5rem !important;\n }\n .ms-xl-5 {\n margin-right: 3rem !important;\n }\n .ms-xl-auto {\n margin-right: auto !important;\n }\n .p-xl-0 {\n padding: 0 !important;\n }\n .p-xl-1 {\n padding: 0.25rem !important;\n }\n .p-xl-2 {\n padding: 0.5rem !important;\n }\n .p-xl-3 {\n padding: 1rem !important;\n }\n .p-xl-4 {\n padding: 1.5rem !important;\n }\n .p-xl-5 {\n padding: 3rem !important;\n }\n .px-xl-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-xl-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-xl-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-xl-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-xl-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-xl-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-xl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xl-0 {\n padding-top: 0 !important;\n }\n .pt-xl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xl-3 {\n padding-top: 1rem !important;\n }\n .pt-xl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xl-5 {\n padding-top: 3rem !important;\n }\n .pe-xl-0 {\n padding-left: 0 !important;\n }\n .pe-xl-1 {\n padding-left: 0.25rem !important;\n }\n .pe-xl-2 {\n padding-left: 0.5rem !important;\n }\n .pe-xl-3 {\n padding-left: 1rem !important;\n }\n .pe-xl-4 {\n padding-left: 1.5rem !important;\n }\n .pe-xl-5 {\n padding-left: 3rem !important;\n }\n .pb-xl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xl-0 {\n padding-right: 0 !important;\n }\n .ps-xl-1 {\n padding-right: 0.25rem !important;\n }\n .ps-xl-2 {\n padding-right: 0.5rem !important;\n }\n .ps-xl-3 {\n padding-right: 1rem !important;\n }\n .ps-xl-4 {\n padding-right: 1.5rem !important;\n }\n .ps-xl-5 {\n padding-right: 3rem !important;\n }\n}\n@media (min-width: 1400px) {\n .d-xxl-inline {\n display: inline !important;\n }\n .d-xxl-inline-block {\n display: inline-block !important;\n }\n .d-xxl-block {\n display: block !important;\n }\n .d-xxl-grid {\n display: grid !important;\n }\n .d-xxl-inline-grid {\n display: inline-grid !important;\n }\n .d-xxl-table {\n display: table !important;\n }\n .d-xxl-table-row {\n display: table-row !important;\n }\n .d-xxl-table-cell {\n display: table-cell !important;\n }\n .d-xxl-flex {\n display: flex !important;\n }\n .d-xxl-inline-flex {\n display: inline-flex !important;\n }\n .d-xxl-none {\n display: none !important;\n }\n .flex-xxl-fill {\n flex: 1 1 auto !important;\n }\n .flex-xxl-row {\n flex-direction: row !important;\n }\n .flex-xxl-column {\n flex-direction: column !important;\n }\n .flex-xxl-row-reverse {\n flex-direction: row-reverse !important;\n }\n .flex-xxl-column-reverse {\n flex-direction: column-reverse !important;\n }\n .flex-xxl-grow-0 {\n flex-grow: 0 !important;\n }\n .flex-xxl-grow-1 {\n flex-grow: 1 !important;\n }\n .flex-xxl-shrink-0 {\n flex-shrink: 0 !important;\n }\n .flex-xxl-shrink-1 {\n flex-shrink: 1 !important;\n }\n .flex-xxl-wrap {\n flex-wrap: wrap !important;\n }\n .flex-xxl-nowrap {\n flex-wrap: nowrap !important;\n }\n .flex-xxl-wrap-reverse {\n flex-wrap: wrap-reverse !important;\n }\n .justify-content-xxl-start {\n justify-content: flex-start !important;\n }\n .justify-content-xxl-end {\n justify-content: flex-end !important;\n }\n .justify-content-xxl-center {\n justify-content: center !important;\n }\n .justify-content-xxl-between {\n justify-content: space-between !important;\n }\n .justify-content-xxl-around {\n justify-content: space-around !important;\n }\n .justify-content-xxl-evenly {\n justify-content: space-evenly !important;\n }\n .align-items-xxl-start {\n align-items: flex-start !important;\n }\n .align-items-xxl-end {\n align-items: flex-end !important;\n }\n .align-items-xxl-center {\n align-items: center !important;\n }\n .align-items-xxl-baseline {\n align-items: baseline !important;\n }\n .align-items-xxl-stretch {\n align-items: stretch !important;\n }\n .align-content-xxl-start {\n align-content: flex-start !important;\n }\n .align-content-xxl-end {\n align-content: flex-end !important;\n }\n .align-content-xxl-center {\n align-content: center !important;\n }\n .align-content-xxl-between {\n align-content: space-between !important;\n }\n .align-content-xxl-around {\n align-content: space-around !important;\n }\n .align-content-xxl-stretch {\n align-content: stretch !important;\n }\n .align-self-xxl-auto {\n align-self: auto !important;\n }\n .align-self-xxl-start {\n align-self: flex-start !important;\n }\n .align-self-xxl-end {\n align-self: flex-end !important;\n }\n .align-self-xxl-center {\n align-self: center !important;\n }\n .align-self-xxl-baseline {\n align-self: baseline !important;\n }\n .align-self-xxl-stretch {\n align-self: stretch !important;\n }\n .order-xxl-first {\n order: -1 !important;\n }\n .order-xxl-0 {\n order: 0 !important;\n }\n .order-xxl-1 {\n order: 1 !important;\n }\n .order-xxl-2 {\n order: 2 !important;\n }\n .order-xxl-3 {\n order: 3 !important;\n }\n .order-xxl-4 {\n order: 4 !important;\n }\n .order-xxl-5 {\n order: 5 !important;\n }\n .order-xxl-last {\n order: 6 !important;\n }\n .m-xxl-0 {\n margin: 0 !important;\n }\n .m-xxl-1 {\n margin: 0.25rem !important;\n }\n .m-xxl-2 {\n margin: 0.5rem !important;\n }\n .m-xxl-3 {\n margin: 1rem !important;\n }\n .m-xxl-4 {\n margin: 1.5rem !important;\n }\n .m-xxl-5 {\n margin: 3rem !important;\n }\n .m-xxl-auto {\n margin: auto !important;\n }\n .mx-xxl-0 {\n margin-left: 0 !important;\n margin-right: 0 !important;\n }\n .mx-xxl-1 {\n margin-left: 0.25rem !important;\n margin-right: 0.25rem !important;\n }\n .mx-xxl-2 {\n margin-left: 0.5rem !important;\n margin-right: 0.5rem !important;\n }\n .mx-xxl-3 {\n margin-left: 1rem !important;\n margin-right: 1rem !important;\n }\n .mx-xxl-4 {\n margin-left: 1.5rem !important;\n margin-right: 1.5rem !important;\n }\n .mx-xxl-5 {\n margin-left: 3rem !important;\n margin-right: 3rem !important;\n }\n .mx-xxl-auto {\n margin-left: auto !important;\n margin-right: auto !important;\n }\n .my-xxl-0 {\n margin-top: 0 !important;\n margin-bottom: 0 !important;\n }\n .my-xxl-1 {\n margin-top: 0.25rem !important;\n margin-bottom: 0.25rem !important;\n }\n .my-xxl-2 {\n margin-top: 0.5rem !important;\n margin-bottom: 0.5rem !important;\n }\n .my-xxl-3 {\n margin-top: 1rem !important;\n margin-bottom: 1rem !important;\n }\n .my-xxl-4 {\n margin-top: 1.5rem !important;\n margin-bottom: 1.5rem !important;\n }\n .my-xxl-5 {\n margin-top: 3rem !important;\n margin-bottom: 3rem !important;\n }\n .my-xxl-auto {\n margin-top: auto !important;\n margin-bottom: auto !important;\n }\n .mt-xxl-0 {\n margin-top: 0 !important;\n }\n .mt-xxl-1 {\n margin-top: 0.25rem !important;\n }\n .mt-xxl-2 {\n margin-top: 0.5rem !important;\n }\n .mt-xxl-3 {\n margin-top: 1rem !important;\n }\n .mt-xxl-4 {\n margin-top: 1.5rem !important;\n }\n .mt-xxl-5 {\n margin-top: 3rem !important;\n }\n .mt-xxl-auto {\n margin-top: auto !important;\n }\n .me-xxl-0 {\n margin-left: 0 !important;\n }\n .me-xxl-1 {\n margin-left: 0.25rem !important;\n }\n .me-xxl-2 {\n margin-left: 0.5rem !important;\n }\n .me-xxl-3 {\n margin-left: 1rem !important;\n }\n .me-xxl-4 {\n margin-left: 1.5rem !important;\n }\n .me-xxl-5 {\n margin-left: 3rem !important;\n }\n .me-xxl-auto {\n margin-left: auto !important;\n }\n .mb-xxl-0 {\n margin-bottom: 0 !important;\n }\n .mb-xxl-1 {\n margin-bottom: 0.25rem !important;\n }\n .mb-xxl-2 {\n margin-bottom: 0.5rem !important;\n }\n .mb-xxl-3 {\n margin-bottom: 1rem !important;\n }\n .mb-xxl-4 {\n margin-bottom: 1.5rem !important;\n }\n .mb-xxl-5 {\n margin-bottom: 3rem !important;\n }\n .mb-xxl-auto {\n margin-bottom: auto !important;\n }\n .ms-xxl-0 {\n margin-right: 0 !important;\n }\n .ms-xxl-1 {\n margin-right: 0.25rem !important;\n }\n .ms-xxl-2 {\n margin-right: 0.5rem !important;\n }\n .ms-xxl-3 {\n margin-right: 1rem !important;\n }\n .ms-xxl-4 {\n margin-right: 1.5rem !important;\n }\n .ms-xxl-5 {\n margin-right: 3rem !important;\n }\n .ms-xxl-auto {\n margin-right: auto !important;\n }\n .p-xxl-0 {\n padding: 0 !important;\n }\n .p-xxl-1 {\n padding: 0.25rem !important;\n }\n .p-xxl-2 {\n padding: 0.5rem !important;\n }\n .p-xxl-3 {\n padding: 1rem !important;\n }\n .p-xxl-4 {\n padding: 1.5rem !important;\n }\n .p-xxl-5 {\n padding: 3rem !important;\n }\n .px-xxl-0 {\n padding-left: 0 !important;\n padding-right: 0 !important;\n }\n .px-xxl-1 {\n padding-left: 0.25rem !important;\n padding-right: 0.25rem !important;\n }\n .px-xxl-2 {\n padding-left: 0.5rem !important;\n padding-right: 0.5rem !important;\n }\n .px-xxl-3 {\n padding-left: 1rem !important;\n padding-right: 1rem !important;\n }\n .px-xxl-4 {\n padding-left: 1.5rem !important;\n padding-right: 1.5rem !important;\n }\n .px-xxl-5 {\n padding-left: 3rem !important;\n padding-right: 3rem !important;\n }\n .py-xxl-0 {\n padding-top: 0 !important;\n padding-bottom: 0 !important;\n }\n .py-xxl-1 {\n padding-top: 0.25rem !important;\n padding-bottom: 0.25rem !important;\n }\n .py-xxl-2 {\n padding-top: 0.5rem !important;\n padding-bottom: 0.5rem !important;\n }\n .py-xxl-3 {\n padding-top: 1rem !important;\n padding-bottom: 1rem !important;\n }\n .py-xxl-4 {\n padding-top: 1.5rem !important;\n padding-bottom: 1.5rem !important;\n }\n .py-xxl-5 {\n padding-top: 3rem !important;\n padding-bottom: 3rem !important;\n }\n .pt-xxl-0 {\n padding-top: 0 !important;\n }\n .pt-xxl-1 {\n padding-top: 0.25rem !important;\n }\n .pt-xxl-2 {\n padding-top: 0.5rem !important;\n }\n .pt-xxl-3 {\n padding-top: 1rem !important;\n }\n .pt-xxl-4 {\n padding-top: 1.5rem !important;\n }\n .pt-xxl-5 {\n padding-top: 3rem !important;\n }\n .pe-xxl-0 {\n padding-left: 0 !important;\n }\n .pe-xxl-1 {\n padding-left: 0.25rem !important;\n }\n .pe-xxl-2 {\n padding-left: 0.5rem !important;\n }\n .pe-xxl-3 {\n padding-left: 1rem !important;\n }\n .pe-xxl-4 {\n padding-left: 1.5rem !important;\n }\n .pe-xxl-5 {\n padding-left: 3rem !important;\n }\n .pb-xxl-0 {\n padding-bottom: 0 !important;\n }\n .pb-xxl-1 {\n padding-bottom: 0.25rem !important;\n }\n .pb-xxl-2 {\n padding-bottom: 0.5rem !important;\n }\n .pb-xxl-3 {\n padding-bottom: 1rem !important;\n }\n .pb-xxl-4 {\n padding-bottom: 1.5rem !important;\n }\n .pb-xxl-5 {\n padding-bottom: 3rem !important;\n }\n .ps-xxl-0 {\n padding-right: 0 !important;\n }\n .ps-xxl-1 {\n padding-right: 0.25rem !important;\n }\n .ps-xxl-2 {\n padding-right: 0.5rem !important;\n }\n .ps-xxl-3 {\n padding-right: 1rem !important;\n }\n .ps-xxl-4 {\n padding-right: 1.5rem !important;\n }\n .ps-xxl-5 {\n padding-right: 3rem !important;\n }\n}\n@media print {\n .d-print-inline {\n display: inline !important;\n }\n .d-print-inline-block {\n display: inline-block !important;\n }\n .d-print-block {\n display: block !important;\n }\n .d-print-grid {\n display: grid !important;\n }\n .d-print-inline-grid {\n display: inline-grid !important;\n }\n .d-print-table {\n display: table !important;\n }\n .d-print-table-row {\n display: table-row !important;\n }\n .d-print-table-cell {\n display: table-cell !important;\n }\n .d-print-flex {\n display: flex !important;\n }\n .d-print-inline-flex {\n display: inline-flex !important;\n }\n .d-print-none {\n display: none !important;\n }\n}\n/*# sourceMappingURL=bootstrap-grid.rtl.css.map */","// Container mixins\n\n@mixin make-container($gutter: $container-padding-x) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n width: 100%;\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-right: auto;\n margin-left: auto;\n}\n","// Breakpoint viewport sizes and media queries.\n//\n// Breakpoints are defined as a map of (name: minimum width), order from small to large:\n//\n// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px)\n//\n// The map defined in the `$grid-breakpoints` global variable is used as the `$breakpoints` argument by default.\n\n// Name of the next breakpoint, or null for the last breakpoint.\n//\n// >> breakpoint-next(sm)\n// md\n// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// md\n// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl xxl))\n// md\n@function breakpoint-next($name, $breakpoints: $grid-breakpoints, $breakpoint-names: map-keys($breakpoints)) {\n $n: index($breakpoint-names, $name);\n @if not $n {\n @error \"breakpoint `#{$name}` not found in `#{$breakpoints}`\";\n }\n @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null);\n}\n\n// Minimum breakpoint width. Null for the smallest (first) breakpoint.\n//\n// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 576px\n@function breakpoint-min($name, $breakpoints: $grid-breakpoints) {\n $min: map-get($breakpoints, $name);\n @return if($min != 0, $min, null);\n}\n\n// Maximum breakpoint width.\n// The maximum value is reduced by 0.02px to work around the limitations of\n// `min-` and `max-` prefixes and viewports with fractional widths.\n// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max\n// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari.\n// See https://bugs.webkit.org/show_bug.cgi?id=178261\n//\n// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// 767.98px\n@function breakpoint-max($name, $breakpoints: $grid-breakpoints) {\n $max: map-get($breakpoints, $name);\n @return if($max and $max > 0, $max - .02, null);\n}\n\n// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash in front.\n// Useful for making responsive utilities.\n//\n// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"\" (Returns a blank string)\n// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px, xxl: 1400px))\n// \"-sm\"\n@function breakpoint-infix($name, $breakpoints: $grid-breakpoints) {\n @return if(breakpoint-min($name, $breakpoints) == null, \"\", \"-#{$name}\");\n}\n\n// Media of at least the minimum breakpoint width. No query for the smallest breakpoint.\n// Makes the @content apply to the given breakpoint and wider.\n@mixin media-breakpoint-up($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n @if $min {\n @media (min-width: $min) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media of at most the maximum breakpoint width. No query for the largest breakpoint.\n// Makes the @content apply to the given breakpoint and narrower.\n@mixin media-breakpoint-down($name, $breakpoints: $grid-breakpoints) {\n $max: breakpoint-max($name, $breakpoints);\n @if $max {\n @media (max-width: $max) {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Media that spans multiple breakpoint widths.\n// Makes the @content apply between the min and max breakpoints\n@mixin media-breakpoint-between($lower, $upper, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($lower, $breakpoints);\n $max: breakpoint-max($upper, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($lower, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($upper, $breakpoints) {\n @content;\n }\n }\n}\n\n// Media between the breakpoint's minimum and maximum widths.\n// No minimum for the smallest breakpoint, and no maximum for the largest one.\n// Makes the @content apply only to the given breakpoint, not viewports any wider or narrower.\n@mixin media-breakpoint-only($name, $breakpoints: $grid-breakpoints) {\n $min: breakpoint-min($name, $breakpoints);\n $next: breakpoint-next($name, $breakpoints);\n $max: breakpoint-max($next, $breakpoints);\n\n @if $min != null and $max != null {\n @media (min-width: $min) and (max-width: $max) {\n @content;\n }\n } @else if $max == null {\n @include media-breakpoint-up($name, $breakpoints) {\n @content;\n }\n } @else if $min == null {\n @include media-breakpoint-down($next, $breakpoints) {\n @content;\n }\n }\n}\n","// Row\n//\n// Rows contain your columns.\n\n:root {\n @each $name, $value in $grid-breakpoints {\n --#{$prefix}breakpoint-#{$name}: #{$value};\n }\n}\n\n@if $enable-grid-classes {\n .row {\n @include make-row();\n\n > * {\n @include make-col-ready();\n }\n }\n}\n\n@if $enable-cssgrid {\n .grid {\n display: grid;\n grid-template-rows: repeat(var(--#{$prefix}rows, 1), 1fr);\n grid-template-columns: repeat(var(--#{$prefix}columns, #{$grid-columns}), 1fr);\n gap: var(--#{$prefix}gap, #{$grid-gutter-width});\n\n @include make-cssgrid();\n }\n}\n\n\n// Columns\n//\n// Common styles for small and large grid columns\n\n@if $enable-grid-classes {\n @include make-grid-columns();\n}\n","// Grid system\n//\n// Generate semantic grid columns with these mixins.\n\n@mixin make-row($gutter: $grid-gutter-width) {\n --#{$prefix}gutter-x: #{$gutter};\n --#{$prefix}gutter-y: 0;\n display: flex;\n flex-wrap: wrap;\n // TODO: Revisit calc order after https://github.com/react-bootstrap/react-bootstrap/issues/6039 is fixed\n margin-top: calc(-1 * var(--#{$prefix}gutter-y)); // stylelint-disable-line function-disallowed-list\n margin-right: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n margin-left: calc(-.5 * var(--#{$prefix}gutter-x)); // stylelint-disable-line function-disallowed-list\n}\n\n@mixin make-col-ready() {\n // Add box sizing if only the grid is loaded\n box-sizing: if(variable-exists(include-column-box-sizing) and $include-column-box-sizing, border-box, null);\n // Prevent columns from becoming too narrow when at smaller grid tiers by\n // always setting `width: 100%;`. This works because we set the width\n // later on to override this initial width.\n flex-shrink: 0;\n width: 100%;\n max-width: 100%; // Prevent `.col-auto`, `.col` (& responsive variants) from breaking out the grid\n padding-right: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n padding-left: calc(var(--#{$prefix}gutter-x) * .5); // stylelint-disable-line function-disallowed-list\n margin-top: var(--#{$prefix}gutter-y);\n}\n\n@mixin make-col($size: false, $columns: $grid-columns) {\n @if $size {\n flex: 0 0 auto;\n width: percentage(divide($size, $columns));\n\n } @else {\n flex: 1 1 0;\n max-width: 100%;\n }\n}\n\n@mixin make-col-auto() {\n flex: 0 0 auto;\n width: auto;\n}\n\n@mixin make-col-offset($size, $columns: $grid-columns) {\n $num: divide($size, $columns);\n margin-left: if($num == 0, 0, percentage($num));\n}\n\n// Row columns\n//\n// Specify on a parent element(e.g., .row) to force immediate children into NN\n// number of columns. Supports wrapping to new lines, but does not do a Masonry\n// style grid.\n@mixin row-cols($count) {\n > * {\n flex: 0 0 auto;\n width: percentage(divide(1, $count));\n }\n}\n\n// Framework grid generation\n//\n// Used only by Bootstrap to generate the correct number of grid classes given\n// any value of `$grid-columns`.\n\n@mixin make-grid-columns($columns: $grid-columns, $gutter: $grid-gutter-width, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n // Provide basic `.col-{bp}` classes for equal-width flexbox columns\n .col#{$infix} {\n flex: 1 0 0%; // Flexbugs #4: https://github.com/philipwalton/flexbugs#flexbug-4\n }\n\n .row-cols#{$infix}-auto > * {\n @include make-col-auto();\n }\n\n @if $grid-row-columns > 0 {\n @for $i from 1 through $grid-row-columns {\n .row-cols#{$infix}-#{$i} {\n @include row-cols($i);\n }\n }\n }\n\n .col#{$infix}-auto {\n @include make-col-auto();\n }\n\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .col#{$infix}-#{$i} {\n @include make-col($i, $columns);\n }\n }\n\n // `$columns - 1` because offsetting by the width of an entire row isn't possible\n @for $i from 0 through ($columns - 1) {\n @if not ($infix == \"\" and $i == 0) { // Avoid emitting useless .offset-0\n .offset#{$infix}-#{$i} {\n @include make-col-offset($i, $columns);\n }\n }\n }\n }\n\n // Gutters\n //\n // Make use of `.g-*`, `.gx-*` or `.gy-*` utilities to change spacing between the columns.\n @each $key, $value in $gutters {\n .g#{$infix}-#{$key},\n .gx#{$infix}-#{$key} {\n --#{$prefix}gutter-x: #{$value};\n }\n\n .g#{$infix}-#{$key},\n .gy#{$infix}-#{$key} {\n --#{$prefix}gutter-y: #{$value};\n }\n }\n }\n }\n}\n\n@mixin make-cssgrid($columns: $grid-columns, $breakpoints: $grid-breakpoints) {\n @each $breakpoint in map-keys($breakpoints) {\n $infix: breakpoint-infix($breakpoint, $breakpoints);\n\n @include media-breakpoint-up($breakpoint, $breakpoints) {\n @if $columns > 0 {\n @for $i from 1 through $columns {\n .g-col#{$infix}-#{$i} {\n grid-column: auto / span $i;\n }\n }\n\n // Start with `1` because `0` is an invalid value.\n // Ends with `$columns - 1` because offsetting by the width of an entire row isn't possible.\n @for $i from 1 through ($columns - 1) {\n .g-start#{$infix}-#{$i} {\n grid-column-start: $i;\n }\n }\n }\n }\n }\n}\n","// Utility generator\n// Used to generate utilities & print utilities\n@mixin generate-utility($utility, $infix: \"\", $is-rfs-media-query: false) {\n $values: map-get($utility, values);\n\n // If the values are a list or string, convert it into a map\n @if type-of($values) == \"string\" or type-of(nth($values, 1)) != \"list\" {\n $values: zip($values, $values);\n }\n\n @each $key, $value in $values {\n $properties: map-get($utility, property);\n\n // Multiple properties are possible, for example with vertical or horizontal margins or paddings\n @if type-of($properties) == \"string\" {\n $properties: append((), $properties);\n }\n\n // Use custom class if present\n $property-class: if(map-has-key($utility, class), map-get($utility, class), nth($properties, 1));\n $property-class: if($property-class == null, \"\", $property-class);\n\n // Use custom CSS variable name if present, otherwise default to `class`\n $css-variable-name: if(map-has-key($utility, css-variable-name), map-get($utility, css-variable-name), map-get($utility, class));\n\n // State params to generate pseudo-classes\n $state: if(map-has-key($utility, state), map-get($utility, state), ());\n\n $infix: if($property-class == \"\" and str-slice($infix, 1, 1) == \"-\", str-slice($infix, 2), $infix);\n\n // Don't prefix if value key is null (e.g. with shadow class)\n $property-class-modifier: if($key, if($property-class == \"\" and $infix == \"\", \"\", \"-\") + $key, \"\");\n\n @if map-get($utility, rfs) {\n // Inside the media query\n @if $is-rfs-media-query {\n $val: rfs-value($value);\n\n // Do not render anything if fluid and non fluid values are the same\n $value: if($val == rfs-fluid-value($value), null, $val);\n }\n @else {\n $value: rfs-fluid-value($value);\n }\n }\n\n $is-css-var: map-get($utility, css-var);\n $is-local-vars: map-get($utility, local-vars);\n $is-rtl: map-get($utility, rtl);\n\n @if $value != null {\n @if $is-rtl == false {\n /* rtl:begin:remove */\n }\n\n @if $is-css-var {\n .#{$property-class + $infix + $property-class-modifier} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n --#{$prefix}#{$css-variable-name}: #{$value};\n }\n }\n } @else {\n .#{$property-class + $infix + $property-class-modifier} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n\n @each $pseudo in $state {\n .#{$property-class + $infix + $property-class-modifier}-#{$pseudo}:#{$pseudo} {\n @each $property in $properties {\n @if $is-local-vars {\n @each $local-var, $variable in $is-local-vars {\n --#{$prefix}#{$local-var}: #{$variable};\n }\n }\n #{$property}: $value if($enable-important-utilities, !important, null);\n }\n }\n }\n }\n\n @if $is-rtl == false {\n /* rtl:end:remove */\n }\n }\n }\n}\n","// Loop over each breakpoint\n@each $breakpoint in map-keys($grid-breakpoints) {\n\n // Generate media query if needed\n @include media-breakpoint-up($breakpoint) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix);\n }\n }\n }\n}\n\n// RFS rescaling\n@media (min-width: $rfs-mq-value) {\n @each $breakpoint in map-keys($grid-breakpoints) {\n $infix: breakpoint-infix($breakpoint, $grid-breakpoints);\n\n @if (map-get($grid-breakpoints, $breakpoint) < $rfs-breakpoint) {\n // Loop over each utility property\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Only proceed if responsive media queries are enabled or if it's the base media query\n @if type-of($utility) == \"map\" and map-get($utility, rfs) and (map-get($utility, responsive) or $infix == \"\") {\n @include generate-utility($utility, $infix, true);\n }\n }\n }\n }\n}\n\n\n// Print utilities\n@media print {\n @each $key, $utility in $utilities {\n // The utility can be disabled with `false`, thus check if the utility is a map first\n // Then check if the utility needs print styles\n @if type-of($utility) == \"map\" and map-get($utility, print) == true {\n @include generate-utility($utility, \"-print\");\n }\n }\n}\n"]} \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css new file mode 100644 index 00000000..63054109 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css @@ -0,0 +1,597 @@ +/*! + * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +:root, +[data-bs-theme=light] { + --bs-blue: #0d6efd; + --bs-indigo: #6610f2; + --bs-purple: #6f42c1; + --bs-pink: #d63384; + --bs-red: #dc3545; + --bs-orange: #fd7e14; + --bs-yellow: #ffc107; + --bs-green: #198754; + --bs-teal: #20c997; + --bs-cyan: #0dcaf0; + --bs-black: #000; + --bs-white: #fff; + --bs-gray: #6c757d; + --bs-gray-dark: #343a40; + --bs-gray-100: #f8f9fa; + --bs-gray-200: #e9ecef; + --bs-gray-300: #dee2e6; + --bs-gray-400: #ced4da; + --bs-gray-500: #adb5bd; + --bs-gray-600: #6c757d; + --bs-gray-700: #495057; + --bs-gray-800: #343a40; + --bs-gray-900: #212529; + --bs-primary: #0d6efd; + --bs-secondary: #6c757d; + --bs-success: #198754; + --bs-info: #0dcaf0; + --bs-warning: #ffc107; + --bs-danger: #dc3545; + --bs-light: #f8f9fa; + --bs-dark: #212529; + --bs-primary-rgb: 13, 110, 253; + --bs-secondary-rgb: 108, 117, 125; + --bs-success-rgb: 25, 135, 84; + --bs-info-rgb: 13, 202, 240; + --bs-warning-rgb: 255, 193, 7; + --bs-danger-rgb: 220, 53, 69; + --bs-light-rgb: 248, 249, 250; + --bs-dark-rgb: 33, 37, 41; + --bs-primary-text-emphasis: #052c65; + --bs-secondary-text-emphasis: #2b2f32; + --bs-success-text-emphasis: #0a3622; + --bs-info-text-emphasis: #055160; + --bs-warning-text-emphasis: #664d03; + --bs-danger-text-emphasis: #58151c; + --bs-light-text-emphasis: #495057; + --bs-dark-text-emphasis: #495057; + --bs-primary-bg-subtle: #cfe2ff; + --bs-secondary-bg-subtle: #e2e3e5; + --bs-success-bg-subtle: #d1e7dd; + --bs-info-bg-subtle: #cff4fc; + --bs-warning-bg-subtle: #fff3cd; + --bs-danger-bg-subtle: #f8d7da; + --bs-light-bg-subtle: #fcfcfd; + --bs-dark-bg-subtle: #ced4da; + --bs-primary-border-subtle: #9ec5fe; + --bs-secondary-border-subtle: #c4c8cb; + --bs-success-border-subtle: #a3cfbb; + --bs-info-border-subtle: #9eeaf9; + --bs-warning-border-subtle: #ffe69c; + --bs-danger-border-subtle: #f1aeb5; + --bs-light-border-subtle: #e9ecef; + --bs-dark-border-subtle: #adb5bd; + --bs-white-rgb: 255, 255, 255; + --bs-black-rgb: 0, 0, 0; + --bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0)); + --bs-body-font-family: var(--bs-font-sans-serif); + --bs-body-font-size: 1rem; + --bs-body-font-weight: 400; + --bs-body-line-height: 1.5; + --bs-body-color: #212529; + --bs-body-color-rgb: 33, 37, 41; + --bs-body-bg: #fff; + --bs-body-bg-rgb: 255, 255, 255; + --bs-emphasis-color: #000; + --bs-emphasis-color-rgb: 0, 0, 0; + --bs-secondary-color: rgba(33, 37, 41, 0.75); + --bs-secondary-color-rgb: 33, 37, 41; + --bs-secondary-bg: #e9ecef; + --bs-secondary-bg-rgb: 233, 236, 239; + --bs-tertiary-color: rgba(33, 37, 41, 0.5); + --bs-tertiary-color-rgb: 33, 37, 41; + --bs-tertiary-bg: #f8f9fa; + --bs-tertiary-bg-rgb: 248, 249, 250; + --bs-heading-color: inherit; + --bs-link-color: #0d6efd; + --bs-link-color-rgb: 13, 110, 253; + --bs-link-decoration: underline; + --bs-link-hover-color: #0a58ca; + --bs-link-hover-color-rgb: 10, 88, 202; + --bs-code-color: #d63384; + --bs-highlight-color: #212529; + --bs-highlight-bg: #fff3cd; + --bs-border-width: 1px; + --bs-border-style: solid; + --bs-border-color: #dee2e6; + --bs-border-color-translucent: rgba(0, 0, 0, 0.175); + --bs-border-radius: 0.375rem; + --bs-border-radius-sm: 0.25rem; + --bs-border-radius-lg: 0.5rem; + --bs-border-radius-xl: 1rem; + --bs-border-radius-xxl: 2rem; + --bs-border-radius-2xl: var(--bs-border-radius-xxl); + --bs-border-radius-pill: 50rem; + --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); + --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); + --bs-focus-ring-width: 0.25rem; + --bs-focus-ring-opacity: 0.25; + --bs-focus-ring-color: rgba(13, 110, 253, 0.25); + --bs-form-valid-color: #198754; + --bs-form-valid-border-color: #198754; + --bs-form-invalid-color: #dc3545; + --bs-form-invalid-border-color: #dc3545; +} + +[data-bs-theme=dark] { + color-scheme: dark; + --bs-body-color: #dee2e6; + --bs-body-color-rgb: 222, 226, 230; + --bs-body-bg: #212529; + --bs-body-bg-rgb: 33, 37, 41; + --bs-emphasis-color: #fff; + --bs-emphasis-color-rgb: 255, 255, 255; + --bs-secondary-color: rgba(222, 226, 230, 0.75); + --bs-secondary-color-rgb: 222, 226, 230; + --bs-secondary-bg: #343a40; + --bs-secondary-bg-rgb: 52, 58, 64; + --bs-tertiary-color: rgba(222, 226, 230, 0.5); + --bs-tertiary-color-rgb: 222, 226, 230; + --bs-tertiary-bg: #2b3035; + --bs-tertiary-bg-rgb: 43, 48, 53; + --bs-primary-text-emphasis: #6ea8fe; + --bs-secondary-text-emphasis: #a7acb1; + --bs-success-text-emphasis: #75b798; + --bs-info-text-emphasis: #6edff6; + --bs-warning-text-emphasis: #ffda6a; + --bs-danger-text-emphasis: #ea868f; + --bs-light-text-emphasis: #f8f9fa; + --bs-dark-text-emphasis: #dee2e6; + --bs-primary-bg-subtle: #031633; + --bs-secondary-bg-subtle: #161719; + --bs-success-bg-subtle: #051b11; + --bs-info-bg-subtle: #032830; + --bs-warning-bg-subtle: #332701; + --bs-danger-bg-subtle: #2c0b0e; + --bs-light-bg-subtle: #343a40; + --bs-dark-bg-subtle: #1a1d20; + --bs-primary-border-subtle: #084298; + --bs-secondary-border-subtle: #41464b; + --bs-success-border-subtle: #0f5132; + --bs-info-border-subtle: #087990; + --bs-warning-border-subtle: #997404; + --bs-danger-border-subtle: #842029; + --bs-light-border-subtle: #495057; + --bs-dark-border-subtle: #343a40; + --bs-heading-color: inherit; + --bs-link-color: #6ea8fe; + --bs-link-hover-color: #8bb9fe; + --bs-link-color-rgb: 110, 168, 254; + --bs-link-hover-color-rgb: 139, 185, 254; + --bs-code-color: #e685b5; + --bs-highlight-color: #dee2e6; + --bs-highlight-bg: #664d03; + --bs-border-color: #495057; + --bs-border-color-translucent: rgba(255, 255, 255, 0.15); + --bs-form-valid-color: #75b798; + --bs-form-valid-border-color: #75b798; + --bs-form-invalid-color: #ea868f; + --bs-form-invalid-border-color: #ea868f; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +@media (prefers-reduced-motion: no-preference) { + :root { + scroll-behavior: smooth; + } +} + +body { + margin: 0; + font-family: var(--bs-body-font-family); + font-size: var(--bs-body-font-size); + font-weight: var(--bs-body-font-weight); + line-height: var(--bs-body-line-height); + color: var(--bs-body-color); + text-align: var(--bs-body-text-align); + background-color: var(--bs-body-bg); + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +hr { + margin: 1rem 0; + color: inherit; + border: 0; + border-top: var(--bs-border-width) solid; + opacity: 0.25; +} + +h6, h5, h4, h3, h2, h1 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; + color: var(--bs-heading-color); +} + +h1 { + font-size: calc(1.375rem + 1.5vw); +} +@media (min-width: 1200px) { + h1 { + font-size: 2.5rem; + } +} + +h2 { + font-size: calc(1.325rem + 0.9vw); +} +@media (min-width: 1200px) { + h2 { + font-size: 2rem; + } +} + +h3 { + font-size: calc(1.3rem + 0.6vw); +} +@media (min-width: 1200px) { + h3 { + font-size: 1.75rem; + } +} + +h4 { + font-size: calc(1.275rem + 0.3vw); +} +@media (min-width: 1200px) { + h4 { + font-size: 1.5rem; + } +} + +h5 { + font-size: 1.25rem; +} + +h6 { + font-size: 1rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title] { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + cursor: help; + -webkit-text-decoration-skip-ink: none; + text-decoration-skip-ink: none; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul { + padding-left: 2rem; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: 0.5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +b, +strong { + font-weight: bolder; +} + +small { + font-size: 0.875em; +} + +mark { + padding: 0.1875em; + color: var(--bs-highlight-color); + background-color: var(--bs-highlight-bg); +} + +sub, +sup { + position: relative; + font-size: 0.75em; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +a { + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); + text-decoration: underline; +} +a:hover { + --bs-link-color-rgb: var(--bs-link-hover-color-rgb); +} + +a:not([href]):not([class]), a:not([href]):not([class]):hover { + color: inherit; + text-decoration: none; +} + +pre, +code, +kbd, +samp { + font-family: var(--bs-font-monospace); + font-size: 1em; +} + +pre { + display: block; + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + font-size: 0.875em; +} +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +code { + font-size: 0.875em; + color: var(--bs-code-color); + word-wrap: break-word; +} +a > code { + color: inherit; +} + +kbd { + padding: 0.1875rem 0.375rem; + font-size: 0.875em; + color: var(--bs-body-bg); + background-color: var(--bs-body-color); + border-radius: 0.25rem; +} +kbd kbd { + padding: 0; + font-size: 1em; +} + +figure { + margin: 0 0 1rem; +} + +img, +svg { + vertical-align: middle; +} + +table { + caption-side: bottom; + border-collapse: collapse; +} + +caption { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + color: var(--bs-secondary-color); + text-align: left; +} + +th { + text-align: inherit; + text-align: -webkit-match-parent; +} + +thead, +tbody, +tfoot, +tr, +td, +th { + border-color: inherit; + border-style: solid; + border-width: 0; +} + +label { + display: inline-block; +} + +button { + border-radius: 0; +} + +button:focus:not(:focus-visible) { + outline: 0; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +select { + text-transform: none; +} + +[role=button] { + cursor: pointer; +} + +select { + word-wrap: normal; +} +select:disabled { + opacity: 1; +} + +[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator { + display: none !important; +} + +button, +[type=button], +[type=reset], +[type=submit] { + -webkit-appearance: button; +} +button:not(:disabled), +[type=button]:not(:disabled), +[type=reset]:not(:disabled), +[type=submit]:not(:disabled) { + cursor: pointer; +} + +::-moz-focus-inner { + padding: 0; + border-style: none; +} + +textarea { + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + float: left; + width: 100%; + padding: 0; + margin-bottom: 0.5rem; + font-size: calc(1.275rem + 0.3vw); + line-height: inherit; +} +@media (min-width: 1200px) { + legend { + font-size: 1.5rem; + } +} +legend + * { + clear: left; +} + +::-webkit-datetime-edit-fields-wrapper, +::-webkit-datetime-edit-text, +::-webkit-datetime-edit-minute, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-year-field { + padding: 0; +} + +::-webkit-inner-spin-button { + height: auto; +} + +[type=search] { + -webkit-appearance: textfield; + outline-offset: -2px; +} + +/* rtl:raw: +[type="tel"], +[type="url"], +[type="email"], +[type="number"] { + direction: ltr; +} +*/ +::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-color-swatch-wrapper { + padding: 0; +} + +::-webkit-file-upload-button { + font: inherit; + -webkit-appearance: button; +} + +::file-selector-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +iframe { + border: 0; +} + +summary { + display: list-item; + cursor: pointer; +} + +progress { + vertical-align: baseline; +} + +[hidden] { + display: none !important; +} + +/*# sourceMappingURL=bootstrap-reboot.css.map */ \ No newline at end of file diff --git a/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map new file mode 100644 index 00000000..5fe522b6 --- /dev/null +++ b/docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm/wwwroot/lib/bootstrap/dist/css/bootstrap-reboot.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","../../scss/vendor/_rfs.scss","bootstrap-reboot.css","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/_variables.scss","../../scss/mixins/_border-radius.scss"],"names":[],"mappings":"AACE;;;;EAAA;ACDF;;EASI,kBAAA;EAAA,oBAAA;EAAA,oBAAA;EAAA,kBAAA;EAAA,iBAAA;EAAA,oBAAA;EAAA,oBAAA;EAAA,mBAAA;EAAA,kBAAA;EAAA,kBAAA;EAAA,gBAAA;EAAA,gBAAA;EAAA,kBAAA;EAAA,uBAAA;EAIA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAAA,sBAAA;EAIA,qBAAA;EAAA,uBAAA;EAAA,qBAAA;EAAA,kBAAA;EAAA,qBAAA;EAAA,oBAAA;EAAA,mBAAA;EAAA,kBAAA;EAIA,8BAAA;EAAA,iCAAA;EAAA,6BAAA;EAAA,2BAAA;EAAA,6BAAA;EAAA,4BAAA;EAAA,6BAAA;EAAA,yBAAA;EAIA,mCAAA;EAAA,qCAAA;EAAA,mCAAA;EAAA,gCAAA;EAAA,mCAAA;EAAA,kCAAA;EAAA,iCAAA;EAAA,gCAAA;EAIA,+BAAA;EAAA,iCAAA;EAAA,+BAAA;EAAA,4BAAA;EAAA,+BAAA;EAAA,8BAAA;EAAA,6BAAA;EAAA,4BAAA;EAIA,mCAAA;EAAA,qCAAA;EAAA,mCAAA;EAAA,gCAAA;EAAA,mCAAA;EAAA,kCAAA;EAAA,iCAAA;EAAA,gCAAA;EAGF,6BAAA;EACA,uBAAA;EAMA,qNAAA;EACA,yGAAA;EACA,yFAAA;EAOA,gDAAA;EC2OI,yBALI;EDpOR,0BAAA;EACA,0BAAA;EAKA,wBAAA;EACA,+BAAA;EACA,kBAAA;EACA,+BAAA;EAEA,yBAAA;EACA,gCAAA;EAEA,4CAAA;EACA,oCAAA;EACA,0BAAA;EACA,oCAAA;EAEA,0CAAA;EACA,mCAAA;EACA,yBAAA;EACA,mCAAA;EAGA,2BAAA;EAEA,wBAAA;EACA,iCAAA;EACA,+BAAA;EAEA,8BAAA;EACA,sCAAA;EAMA,wBAAA;EACA,6BAAA;EACA,0BAAA;EAGA,sBAAA;EACA,wBAAA;EACA,0BAAA;EACA,mDAAA;EAEA,4BAAA;EACA,8BAAA;EACA,6BAAA;EACA,2BAAA;EACA,4BAAA;EACA,mDAAA;EACA,8BAAA;EAGA,kDAAA;EACA,2DAAA;EACA,oDAAA;EACA,2DAAA;EAIA,8BAAA;EACA,6BAAA;EACA,+CAAA;EAIA,8BAAA;EACA,qCAAA;EACA,gCAAA;EACA,uCAAA;AEHF;;AC7GI;EHsHA,kBAAA;EAGA,wBAAA;EACA,kCAAA;EACA,qBAAA;EACA,4BAAA;EAEA,yBAAA;EACA,sCAAA;EAEA,+CAAA;EACA,uCAAA;EACA,0BAAA;EACA,iCAAA;EAEA,6CAAA;EACA,sCAAA;EACA,yBAAA;EACA,gCAAA;EAGE,mCAAA;EAAA,qCAAA;EAAA,mCAAA;EAAA,gCAAA;EAAA,mCAAA;EAAA,kCAAA;EAAA,iCAAA;EAAA,gCAAA;EAIA,+BAAA;EAAA,iCAAA;EAAA,+BAAA;EAAA,4BAAA;EAAA,+BAAA;EAAA,8BAAA;EAAA,6BAAA;EAAA,4BAAA;EAIA,mCAAA;EAAA,qCAAA;EAAA,mCAAA;EAAA,gCAAA;EAAA,mCAAA;EAAA,kCAAA;EAAA,iCAAA;EAAA,gCAAA;EAGF,2BAAA;EAEA,wBAAA;EACA,8BAAA;EACA,kCAAA;EACA,wCAAA;EAEA,wBAAA;EACA,6BAAA;EACA,0BAAA;EAEA,0BAAA;EACA,wDAAA;EAEA,8BAAA;EACA,qCAAA;EACA,gCAAA;EACA,uCAAA;AEHJ;;AErKA;;;EAGE,sBAAA;AFwKF;;AEzJI;EANJ;IAOM,uBAAA;EF6JJ;AACF;;AEhJA;EACE,SAAA;EACA,uCAAA;EH6OI,mCALI;EGtOR,uCAAA;EACA,uCAAA;EACA,2BAAA;EACA,qCAAA;EACA,mCAAA;EACA,8BAAA;EACA,6CAAA;AFmJF;;AE1IA;EACE,cAAA;EACA,cCmnB4B;EDlnB5B,SAAA;EACA,wCAAA;EACA,aCynB4B;AH5e9B;;AEnIA;EACE,aAAA;EACA,qBCwjB4B;EDrjB5B,gBCwjB4B;EDvjB5B,gBCwjB4B;EDvjB5B,8BAAA;AFoIF;;AEjIA;EHuMQ,iCAAA;AClER;AD1FI;EG3CJ;IH8MQ,iBAAA;ECrEN;AACF;;AErIA;EHkMQ,iCAAA;ACzDR;ADnGI;EGtCJ;IHyMQ,eAAA;EC5DN;AACF;;AEzIA;EH6LQ,+BAAA;AChDR;AD5GI;EGjCJ;IHoMQ,kBAAA;ECnDN;AACF;;AE7IA;EHwLQ,iCAAA;ACvCR;ADrHI;EG5BJ;IH+LQ,iBAAA;EC1CN;AACF;;AEjJA;EH+KM,kBALI;ACrBV;;AEhJA;EH0KM,eALI;ACjBV;;AEzIA;EACE,aAAA;EACA,mBCwV0B;AH5M5B;;AElIA;EACE,yCAAA;EAAA,iCAAA;EACA,YAAA;EACA,sCAAA;EAAA,8BAAA;AFqIF;;AE/HA;EACE,mBAAA;EACA,kBAAA;EACA,oBAAA;AFkIF;;AE5HA;;EAEE,kBAAA;AF+HF;;AE5HA;;;EAGE,aAAA;EACA,mBAAA;AF+HF;;AE5HA;;;;EAIE,gBAAA;AF+HF;;AE5HA;EACE,gBC6b4B;AH9T9B;;AE1HA;EACE,qBAAA;EACA,cAAA;AF6HF;;AEvHA;EACE,gBAAA;AF0HF;;AElHA;;EAEE,mBCsa4B;AHjT9B;;AE7GA;EH6EM,kBALI;ACyCV;;AE1GA;EACE,iBCqf4B;EDpf5B,gCAAA;EACA,wCAAA;AF6GF;;AEpGA;;EAEE,kBAAA;EHwDI,iBALI;EGjDR,cAAA;EACA,wBAAA;AFuGF;;AEpGA;EAAM,eAAA;AFwGN;;AEvGA;EAAM,WAAA;AF2GN;;AEtGA;EACE,gEAAA;EACA,0BCgNwC;AHvG1C;AEvGE;EACE,mDAAA;AFyGJ;;AE9FE;EAEE,cAAA;EACA,qBAAA;AFgGJ;;AEzFA;;;;EAIE,qCCgV4B;EJlUxB,cALI;ACoFV;;AErFA;EACE,cAAA;EACA,aAAA;EACA,mBAAA;EACA,cAAA;EHEI,kBALI;AC4FV;AEpFE;EHHI,kBALI;EGUN,cAAA;EACA,kBAAA;AFsFJ;;AElFA;EHVM,kBALI;EGiBR,2BAAA;EACA,qBAAA;AFqFF;AElFE;EACE,cAAA;AFoFJ;;AEhFA;EACE,2BAAA;EHtBI,kBALI;EG6BR,wBCy5CkC;EDx5ClC,sCCy5CkC;EC9rDhC,sBAAA;AJyXJ;AEjFE;EACE,UAAA;EH7BE,cALI;ACsHV;;AEzEA;EACE,gBAAA;AF4EF;;AEtEA;;EAEE,sBAAA;AFyEF;;AEjEA;EACE,oBAAA;EACA,yBAAA;AFoEF;;AEjEA;EACE,mBC4X4B;ED3X5B,sBC2X4B;ED1X5B,gCC4Z4B;ED3Z5B,gBAAA;AFoEF;;AE7DA;EAEE,mBAAA;EACA,gCAAA;AF+DF;;AE5DA;;;;;;EAME,qBAAA;EACA,mBAAA;EACA,eAAA;AF+DF;;AEvDA;EACE,qBAAA;AF0DF;;AEpDA;EAEE,gBAAA;AFsDF;;AE9CA;EACE,UAAA;AFiDF;;AE5CA;;;;;EAKE,SAAA;EACA,oBAAA;EH5HI,kBALI;EGmIR,oBAAA;AF+CF;;AE3CA;;EAEE,oBAAA;AF8CF;;AEzCA;EACE,eAAA;AF4CF;;AEzCA;EAGE,iBAAA;AF0CF;AEvCE;EACE,UAAA;AFyCJ;;AElCA;EACE,wBAAA;AFqCF;;AE7BA;;;;EAIE,0BAAA;AFgCF;AE7BI;;;;EACE,eAAA;AFkCN;;AE3BA;EACE,UAAA;EACA,kBAAA;AF8BF;;AEzBA;EACE,gBAAA;AF4BF;;AElBA;EACE,YAAA;EACA,UAAA;EACA,SAAA;EACA,SAAA;AFqBF;;AEbA;EACE,WAAA;EACA,WAAA;EACA,UAAA;EACA,qBCmN4B;EJpatB,iCAAA;EGoNN,oBAAA;AFeF;AD/XI;EGyWJ;IHtMQ,iBAAA;ECgON;AACF;AElBE;EACE,WAAA;AFoBJ;;AEbA;;;;;;;EAOE,UAAA;AFgBF;;AEbA;EACE,YAAA;AFgBF;;AEPA;EACE,6BAAA;EACA,oBAAA;AFUF;;AEFA;;;;;;;CAAA;AAWA;EACE,wBAAA;AFEF;;AEGA;EACE,UAAA;AFAF;;AEOA;EACE,aAAA;EACA,0BAAA;AFJF;;AEEA;EACE,aAAA;EACA,0BAAA;AFJF;;AESA;EACE,qBAAA;AFNF;;AEWA;EACE,SAAA;AFRF;;AEeA;EACE,kBAAA;EACA,eAAA;AFZF;;AEoBA;EACE,wBAAA;AFjBF;;AEyBA;EACE,wBAAA;AFtBF","file":"bootstrap-reboot.css","sourcesContent":["@mixin bsBanner($file) {\n /*!\n * Bootstrap #{$file} v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n}\n",":root,\n[data-bs-theme=\"light\"] {\n // Note: Custom variable values only support SassScript inside `#{}`.\n\n // Colors\n //\n // Generate palettes for full colors, grays, and theme colors.\n\n @each $color, $value in $colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $grays {\n --#{$prefix}gray-#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$prefix}#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors-rgb {\n --#{$prefix}#{$color}-rgb: #{$value};\n }\n\n @each $color, $value in $theme-colors-text {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}white-rgb: #{to-rgb($white)};\n --#{$prefix}black-rgb: #{to-rgb($black)};\n\n // Fonts\n\n // Note: Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --#{$prefix}font-sans-serif: #{inspect($font-family-sans-serif)};\n --#{$prefix}font-monospace: #{inspect($font-family-monospace)};\n --#{$prefix}gradient: #{$gradient};\n\n // Root and body\n // scss-docs-start root-body-variables\n @if $font-size-root != null {\n --#{$prefix}root-font-size: #{$font-size-root};\n }\n --#{$prefix}body-font-family: #{inspect($font-family-base)};\n @include rfs($font-size-base, --#{$prefix}body-font-size);\n --#{$prefix}body-font-weight: #{$font-weight-base};\n --#{$prefix}body-line-height: #{$line-height-base};\n @if $body-text-align != null {\n --#{$prefix}body-text-align: #{$body-text-align};\n }\n\n --#{$prefix}body-color: #{$body-color};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color)};\n --#{$prefix}body-bg: #{$body-bg};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg)};\n // scss-docs-end root-body-variables\n\n --#{$prefix}heading-color: #{$headings-color};\n\n --#{$prefix}link-color: #{$link-color};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color)};\n --#{$prefix}link-decoration: #{$link-decoration};\n\n --#{$prefix}link-hover-color: #{$link-hover-color};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color)};\n\n @if $link-hover-decoration != null {\n --#{$prefix}link-hover-decoration: #{$link-hover-decoration};\n }\n\n --#{$prefix}code-color: #{$code-color};\n --#{$prefix}highlight-color: #{$mark-color};\n --#{$prefix}highlight-bg: #{$mark-bg};\n\n // scss-docs-start root-border-var\n --#{$prefix}border-width: #{$border-width};\n --#{$prefix}border-style: #{$border-style};\n --#{$prefix}border-color: #{$border-color};\n --#{$prefix}border-color-translucent: #{$border-color-translucent};\n\n --#{$prefix}border-radius: #{$border-radius};\n --#{$prefix}border-radius-sm: #{$border-radius-sm};\n --#{$prefix}border-radius-lg: #{$border-radius-lg};\n --#{$prefix}border-radius-xl: #{$border-radius-xl};\n --#{$prefix}border-radius-xxl: #{$border-radius-xxl};\n --#{$prefix}border-radius-2xl: var(--#{$prefix}border-radius-xxl); // Deprecated in v5.3.0 for consistency\n --#{$prefix}border-radius-pill: #{$border-radius-pill};\n // scss-docs-end root-border-var\n\n --#{$prefix}box-shadow: #{$box-shadow};\n --#{$prefix}box-shadow-sm: #{$box-shadow-sm};\n --#{$prefix}box-shadow-lg: #{$box-shadow-lg};\n --#{$prefix}box-shadow-inset: #{$box-shadow-inset};\n\n // Focus styles\n // scss-docs-start root-focus-variables\n --#{$prefix}focus-ring-width: #{$focus-ring-width};\n --#{$prefix}focus-ring-opacity: #{$focus-ring-opacity};\n --#{$prefix}focus-ring-color: #{$focus-ring-color};\n // scss-docs-end root-focus-variables\n\n // scss-docs-start root-form-validation-variables\n --#{$prefix}form-valid-color: #{$form-valid-color};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color};\n --#{$prefix}form-invalid-color: #{$form-invalid-color};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color};\n // scss-docs-end root-form-validation-variables\n}\n\n@if $enable-dark-mode {\n @include color-mode(dark, true) {\n color-scheme: dark;\n\n // scss-docs-start root-dark-mode-vars\n --#{$prefix}body-color: #{$body-color-dark};\n --#{$prefix}body-color-rgb: #{to-rgb($body-color-dark)};\n --#{$prefix}body-bg: #{$body-bg-dark};\n --#{$prefix}body-bg-rgb: #{to-rgb($body-bg-dark)};\n\n --#{$prefix}emphasis-color: #{$body-emphasis-color-dark};\n --#{$prefix}emphasis-color-rgb: #{to-rgb($body-emphasis-color-dark)};\n\n --#{$prefix}secondary-color: #{$body-secondary-color-dark};\n --#{$prefix}secondary-color-rgb: #{to-rgb($body-secondary-color-dark)};\n --#{$prefix}secondary-bg: #{$body-secondary-bg-dark};\n --#{$prefix}secondary-bg-rgb: #{to-rgb($body-secondary-bg-dark)};\n\n --#{$prefix}tertiary-color: #{$body-tertiary-color-dark};\n --#{$prefix}tertiary-color-rgb: #{to-rgb($body-tertiary-color-dark)};\n --#{$prefix}tertiary-bg: #{$body-tertiary-bg-dark};\n --#{$prefix}tertiary-bg-rgb: #{to-rgb($body-tertiary-bg-dark)};\n\n @each $color, $value in $theme-colors-text-dark {\n --#{$prefix}#{$color}-text-emphasis: #{$value};\n }\n\n @each $color, $value in $theme-colors-bg-subtle-dark {\n --#{$prefix}#{$color}-bg-subtle: #{$value};\n }\n\n @each $color, $value in $theme-colors-border-subtle-dark {\n --#{$prefix}#{$color}-border-subtle: #{$value};\n }\n\n --#{$prefix}heading-color: #{$headings-color-dark};\n\n --#{$prefix}link-color: #{$link-color-dark};\n --#{$prefix}link-hover-color: #{$link-hover-color-dark};\n --#{$prefix}link-color-rgb: #{to-rgb($link-color-dark)};\n --#{$prefix}link-hover-color-rgb: #{to-rgb($link-hover-color-dark)};\n\n --#{$prefix}code-color: #{$code-color-dark};\n --#{$prefix}highlight-color: #{$mark-color-dark};\n --#{$prefix}highlight-bg: #{$mark-bg-dark};\n\n --#{$prefix}border-color: #{$border-color-dark};\n --#{$prefix}border-color-translucent: #{$border-color-translucent-dark};\n\n --#{$prefix}form-valid-color: #{$form-valid-color-dark};\n --#{$prefix}form-valid-border-color: #{$form-valid-border-color-dark};\n --#{$prefix}form-invalid-color: #{$form-invalid-color-dark};\n --#{$prefix}form-invalid-border-color: #{$form-invalid-border-color-dark};\n // scss-docs-end root-dark-mode-vars\n }\n}\n","// stylelint-disable scss/dimension-no-non-numeric-values\n\n// SCSS RFS mixin\n//\n// Automated responsive values for font sizes, paddings, margins and much more\n//\n// Licensed under MIT (https://github.com/twbs/rfs/blob/main/LICENSE)\n\n// Configuration\n\n// Base value\n$rfs-base-value: 1.25rem !default;\n$rfs-unit: rem !default;\n\n@if $rfs-unit != rem and $rfs-unit != px {\n @error \"`#{$rfs-unit}` is not a valid unit for $rfs-unit. Use `px` or `rem`.\";\n}\n\n// Breakpoint at where values start decreasing if screen width is smaller\n$rfs-breakpoint: 1200px !default;\n$rfs-breakpoint-unit: px !default;\n\n@if $rfs-breakpoint-unit != px and $rfs-breakpoint-unit != em and $rfs-breakpoint-unit != rem {\n @error \"`#{$rfs-breakpoint-unit}` is not a valid unit for $rfs-breakpoint-unit. Use `px`, `em` or `rem`.\";\n}\n\n// Resize values based on screen height and width\n$rfs-two-dimensional: false !default;\n\n// Factor of decrease\n$rfs-factor: 10 !default;\n\n@if type-of($rfs-factor) != number or $rfs-factor <= 1 {\n @error \"`#{$rfs-factor}` is not a valid $rfs-factor, it must be greater than 1.\";\n}\n\n// Mode. Possibilities: \"min-media-query\", \"max-media-query\"\n$rfs-mode: min-media-query !default;\n\n// Generate enable or disable classes. Possibilities: false, \"enable\" or \"disable\"\n$rfs-class: false !default;\n\n// 1 rem = $rfs-rem-value px\n$rfs-rem-value: 16 !default;\n\n// Safari iframe resize bug: https://github.com/twbs/rfs/issues/14\n$rfs-safari-iframe-resize-bug-fix: false !default;\n\n// Disable RFS by setting $enable-rfs to false\n$enable-rfs: true !default;\n\n// Cache $rfs-base-value unit\n$rfs-base-value-unit: unit($rfs-base-value);\n\n@function divide($dividend, $divisor, $precision: 10) {\n $sign: if($dividend > 0 and $divisor > 0 or $dividend < 0 and $divisor < 0, 1, -1);\n $dividend: abs($dividend);\n $divisor: abs($divisor);\n @if $dividend == 0 {\n @return 0;\n }\n @if $divisor == 0 {\n @error \"Cannot divide by 0\";\n }\n $remainder: $dividend;\n $result: 0;\n $factor: 10;\n @while ($remainder > 0 and $precision >= 0) {\n $quotient: 0;\n @while ($remainder >= $divisor) {\n $remainder: $remainder - $divisor;\n $quotient: $quotient + 1;\n }\n $result: $result * 10 + $quotient;\n $factor: $factor * .1;\n $remainder: $remainder * 10;\n $precision: $precision - 1;\n @if ($precision < 0 and $remainder >= $divisor * 5) {\n $result: $result + 1;\n }\n }\n $result: $result * $factor * $sign;\n $dividend-unit: unit($dividend);\n $divisor-unit: unit($divisor);\n $unit-map: (\n \"px\": 1px,\n \"rem\": 1rem,\n \"em\": 1em,\n \"%\": 1%\n );\n @if ($dividend-unit != $divisor-unit and map-has-key($unit-map, $dividend-unit)) {\n $result: $result * map-get($unit-map, $dividend-unit);\n }\n @return $result;\n}\n\n// Remove px-unit from $rfs-base-value for calculations\n@if $rfs-base-value-unit == px {\n $rfs-base-value: divide($rfs-base-value, $rfs-base-value * 0 + 1);\n}\n@else if $rfs-base-value-unit == rem {\n $rfs-base-value: divide($rfs-base-value, divide($rfs-base-value * 0 + 1, $rfs-rem-value));\n}\n\n// Cache $rfs-breakpoint unit to prevent multiple calls\n$rfs-breakpoint-unit-cache: unit($rfs-breakpoint);\n\n// Remove unit from $rfs-breakpoint for calculations\n@if $rfs-breakpoint-unit-cache == px {\n $rfs-breakpoint: divide($rfs-breakpoint, $rfs-breakpoint * 0 + 1);\n}\n@else if $rfs-breakpoint-unit-cache == rem or $rfs-breakpoint-unit-cache == \"em\" {\n $rfs-breakpoint: divide($rfs-breakpoint, divide($rfs-breakpoint * 0 + 1, $rfs-rem-value));\n}\n\n// Calculate the media query value\n$rfs-mq-value: if($rfs-breakpoint-unit == px, #{$rfs-breakpoint}px, #{divide($rfs-breakpoint, $rfs-rem-value)}#{$rfs-breakpoint-unit});\n$rfs-mq-property-width: if($rfs-mode == max-media-query, max-width, min-width);\n$rfs-mq-property-height: if($rfs-mode == max-media-query, max-height, min-height);\n\n// Internal mixin used to determine which media query needs to be used\n@mixin _rfs-media-query {\n @if $rfs-two-dimensional {\n @if $rfs-mode == max-media-query {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}), (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) and (#{$rfs-mq-property-height}: #{$rfs-mq-value}) {\n @content;\n }\n }\n }\n @else {\n @media (#{$rfs-mq-property-width}: #{$rfs-mq-value}) {\n @content;\n }\n }\n}\n\n// Internal mixin that adds disable classes to the selector if needed.\n@mixin _rfs-rule {\n @if $rfs-class == disable and $rfs-mode == max-media-query {\n // Adding an extra class increases specificity, which prevents the media query to override the property\n &,\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @else if $rfs-class == enable and $rfs-mode == min-media-query {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n } @else {\n @content;\n }\n}\n\n// Internal mixin that adds enable classes to the selector if needed.\n@mixin _rfs-media-query-rule {\n\n @if $rfs-class == enable {\n @if $rfs-mode == min-media-query {\n @content;\n }\n\n @include _rfs-media-query () {\n .enable-rfs &,\n &.enable-rfs {\n @content;\n }\n }\n }\n @else {\n @if $rfs-class == disable and $rfs-mode == min-media-query {\n .disable-rfs &,\n &.disable-rfs {\n @content;\n }\n }\n @include _rfs-media-query () {\n @content;\n }\n }\n}\n\n// Helper function to get the formatted non-responsive value\n@function rfs-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n }\n @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n @if $unit == px {\n // Convert to rem if needed\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $value * 0 + $rfs-rem-value)}rem, $value);\n }\n @else if $unit == rem {\n // Convert to px if needed\n $val: $val + \" \" + if($rfs-unit == px, #{divide($value, $value * 0 + 1) * $rfs-rem-value}px, $value);\n } @else {\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n $val: $val + \" \" + $value;\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// Helper function to get the responsive value calculated by RFS\n@function rfs-fluid-value($values) {\n // Convert to list\n $values: if(type-of($values) != list, ($values,), $values);\n\n $val: \"\";\n\n // Loop over each value and calculate value\n @each $value in $values {\n @if $value == 0 {\n $val: $val + \" 0\";\n } @else {\n // Cache $value unit\n $unit: if(type-of($value) == \"number\", unit($value), false);\n\n // If $value isn't a number (like inherit) or $value has a unit (not px or rem, like 1.5em) or $ is 0, just print the value\n @if not $unit or $unit != px and $unit != rem {\n $val: $val + \" \" + $value;\n } @else {\n // Remove unit from $value for calculations\n $value: divide($value, $value * 0 + if($unit == px, 1, divide(1, $rfs-rem-value)));\n\n // Only add the media query if the value is greater than the minimum value\n @if abs($value) <= $rfs-base-value or not $enable-rfs {\n $val: $val + \" \" + if($rfs-unit == rem, #{divide($value, $rfs-rem-value)}rem, #{$value}px);\n }\n @else {\n // Calculate the minimum value\n $value-min: $rfs-base-value + divide(abs($value) - $rfs-base-value, $rfs-factor);\n\n // Calculate difference between $value and the minimum value\n $value-diff: abs($value) - $value-min;\n\n // Base value formatting\n $min-width: if($rfs-unit == rem, #{divide($value-min, $rfs-rem-value)}rem, #{$value-min}px);\n\n // Use negative value if needed\n $min-width: if($value < 0, -$min-width, $min-width);\n\n // Use `vmin` if two-dimensional is enabled\n $variable-unit: if($rfs-two-dimensional, vmin, vw);\n\n // Calculate the variable width between 0 and $rfs-breakpoint\n $variable-width: #{divide($value-diff * 100, $rfs-breakpoint)}#{$variable-unit};\n\n // Return the calculated value\n $val: $val + \" calc(\" + $min-width + if($value < 0, \" - \", \" + \") + $variable-width + \")\";\n }\n }\n }\n }\n\n // Remove first space\n @return unquote(str-slice($val, 2));\n}\n\n// RFS mixin\n@mixin rfs($values, $property: font-size) {\n @if $values != null {\n $val: rfs-value($values);\n $fluid-val: rfs-fluid-value($values);\n\n // Do not print the media query if responsive & non-responsive values are the same\n @if $val == $fluid-val {\n #{$property}: $val;\n }\n @else {\n @include _rfs-rule () {\n #{$property}: if($rfs-mode == max-media-query, $val, $fluid-val);\n\n // Include safari iframe resize fix if needed\n min-width: if($rfs-safari-iframe-resize-bug-fix, (0 * 1vw), null);\n }\n\n @include _rfs-media-query-rule () {\n #{$property}: if($rfs-mode == max-media-query, $fluid-val, $val);\n }\n }\n }\n}\n\n// Shorthand helper mixins\n@mixin font-size($value) {\n @include rfs($value);\n}\n\n@mixin padding($value) {\n @include rfs($value, padding);\n}\n\n@mixin padding-top($value) {\n @include rfs($value, padding-top);\n}\n\n@mixin padding-right($value) {\n @include rfs($value, padding-right);\n}\n\n@mixin padding-bottom($value) {\n @include rfs($value, padding-bottom);\n}\n\n@mixin padding-left($value) {\n @include rfs($value, padding-left);\n}\n\n@mixin margin($value) {\n @include rfs($value, margin);\n}\n\n@mixin margin-top($value) {\n @include rfs($value, margin-top);\n}\n\n@mixin margin-right($value) {\n @include rfs($value, margin-right);\n}\n\n@mixin margin-bottom($value) {\n @include rfs($value, margin-bottom);\n}\n\n@mixin margin-left($value) {\n @include rfs($value, margin-left);\n}\n","/*!\n * Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)\n * Copyright 2011-2024 The Bootstrap Authors\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n:root,\n[data-bs-theme=light] {\n --bs-blue: #0d6efd;\n --bs-indigo: #6610f2;\n --bs-purple: #6f42c1;\n --bs-pink: #d63384;\n --bs-red: #dc3545;\n --bs-orange: #fd7e14;\n --bs-yellow: #ffc107;\n --bs-green: #198754;\n --bs-teal: #20c997;\n --bs-cyan: #0dcaf0;\n --bs-black: #000;\n --bs-white: #fff;\n --bs-gray: #6c757d;\n --bs-gray-dark: #343a40;\n --bs-gray-100: #f8f9fa;\n --bs-gray-200: #e9ecef;\n --bs-gray-300: #dee2e6;\n --bs-gray-400: #ced4da;\n --bs-gray-500: #adb5bd;\n --bs-gray-600: #6c757d;\n --bs-gray-700: #495057;\n --bs-gray-800: #343a40;\n --bs-gray-900: #212529;\n --bs-primary: #0d6efd;\n --bs-secondary: #6c757d;\n --bs-success: #198754;\n --bs-info: #0dcaf0;\n --bs-warning: #ffc107;\n --bs-danger: #dc3545;\n --bs-light: #f8f9fa;\n --bs-dark: #212529;\n --bs-primary-rgb: 13, 110, 253;\n --bs-secondary-rgb: 108, 117, 125;\n --bs-success-rgb: 25, 135, 84;\n --bs-info-rgb: 13, 202, 240;\n --bs-warning-rgb: 255, 193, 7;\n --bs-danger-rgb: 220, 53, 69;\n --bs-light-rgb: 248, 249, 250;\n --bs-dark-rgb: 33, 37, 41;\n --bs-primary-text-emphasis: #052c65;\n --bs-secondary-text-emphasis: #2b2f32;\n --bs-success-text-emphasis: #0a3622;\n --bs-info-text-emphasis: #055160;\n --bs-warning-text-emphasis: #664d03;\n --bs-danger-text-emphasis: #58151c;\n --bs-light-text-emphasis: #495057;\n --bs-dark-text-emphasis: #495057;\n --bs-primary-bg-subtle: #cfe2ff;\n --bs-secondary-bg-subtle: #e2e3e5;\n --bs-success-bg-subtle: #d1e7dd;\n --bs-info-bg-subtle: #cff4fc;\n --bs-warning-bg-subtle: #fff3cd;\n --bs-danger-bg-subtle: #f8d7da;\n --bs-light-bg-subtle: #fcfcfd;\n --bs-dark-bg-subtle: #ced4da;\n --bs-primary-border-subtle: #9ec5fe;\n --bs-secondary-border-subtle: #c4c8cb;\n --bs-success-border-subtle: #a3cfbb;\n --bs-info-border-subtle: #9eeaf9;\n --bs-warning-border-subtle: #ffe69c;\n --bs-danger-border-subtle: #f1aeb5;\n --bs-light-border-subtle: #e9ecef;\n --bs-dark-border-subtle: #adb5bd;\n --bs-white-rgb: 255, 255, 255;\n --bs-black-rgb: 0, 0, 0;\n --bs-font-sans-serif: system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", \"Noto Sans\", \"Liberation Sans\", Arial, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", \"Segoe UI Symbol\", \"Noto Color Emoji\";\n --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, \"Liberation Mono\", \"Courier New\", monospace;\n --bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));\n --bs-body-font-family: var(--bs-font-sans-serif);\n --bs-body-font-size: 1rem;\n --bs-body-font-weight: 400;\n --bs-body-line-height: 1.5;\n --bs-body-color: #212529;\n --bs-body-color-rgb: 33, 37, 41;\n --bs-body-bg: #fff;\n --bs-body-bg-rgb: 255, 255, 255;\n --bs-emphasis-color: #000;\n --bs-emphasis-color-rgb: 0, 0, 0;\n --bs-secondary-color: rgba(33, 37, 41, 0.75);\n --bs-secondary-color-rgb: 33, 37, 41;\n --bs-secondary-bg: #e9ecef;\n --bs-secondary-bg-rgb: 233, 236, 239;\n --bs-tertiary-color: rgba(33, 37, 41, 0.5);\n --bs-tertiary-color-rgb: 33, 37, 41;\n --bs-tertiary-bg: #f8f9fa;\n --bs-tertiary-bg-rgb: 248, 249, 250;\n --bs-heading-color: inherit;\n --bs-link-color: #0d6efd;\n --bs-link-color-rgb: 13, 110, 253;\n --bs-link-decoration: underline;\n --bs-link-hover-color: #0a58ca;\n --bs-link-hover-color-rgb: 10, 88, 202;\n --bs-code-color: #d63384;\n --bs-highlight-color: #212529;\n --bs-highlight-bg: #fff3cd;\n --bs-border-width: 1px;\n --bs-border-style: solid;\n --bs-border-color: #dee2e6;\n --bs-border-color-translucent: rgba(0, 0, 0, 0.175);\n --bs-border-radius: 0.375rem;\n --bs-border-radius-sm: 0.25rem;\n --bs-border-radius-lg: 0.5rem;\n --bs-border-radius-xl: 1rem;\n --bs-border-radius-xxl: 2rem;\n --bs-border-radius-2xl: var(--bs-border-radius-xxl);\n --bs-border-radius-pill: 50rem;\n --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);\n --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);\n --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);\n --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);\n --bs-focus-ring-width: 0.25rem;\n --bs-focus-ring-opacity: 0.25;\n --bs-focus-ring-color: rgba(13, 110, 253, 0.25);\n --bs-form-valid-color: #198754;\n --bs-form-valid-border-color: #198754;\n --bs-form-invalid-color: #dc3545;\n --bs-form-invalid-border-color: #dc3545;\n}\n\n[data-bs-theme=dark] {\n color-scheme: dark;\n --bs-body-color: #dee2e6;\n --bs-body-color-rgb: 222, 226, 230;\n --bs-body-bg: #212529;\n --bs-body-bg-rgb: 33, 37, 41;\n --bs-emphasis-color: #fff;\n --bs-emphasis-color-rgb: 255, 255, 255;\n --bs-secondary-color: rgba(222, 226, 230, 0.75);\n --bs-secondary-color-rgb: 222, 226, 230;\n --bs-secondary-bg: #343a40;\n --bs-secondary-bg-rgb: 52, 58, 64;\n --bs-tertiary-color: rgba(222, 226, 230, 0.5);\n --bs-tertiary-color-rgb: 222, 226, 230;\n --bs-tertiary-bg: #2b3035;\n --bs-tertiary-bg-rgb: 43, 48, 53;\n --bs-primary-text-emphasis: #6ea8fe;\n --bs-secondary-text-emphasis: #a7acb1;\n --bs-success-text-emphasis: #75b798;\n --bs-info-text-emphasis: #6edff6;\n --bs-warning-text-emphasis: #ffda6a;\n --bs-danger-text-emphasis: #ea868f;\n --bs-light-text-emphasis: #f8f9fa;\n --bs-dark-text-emphasis: #dee2e6;\n --bs-primary-bg-subtle: #031633;\n --bs-secondary-bg-subtle: #161719;\n --bs-success-bg-subtle: #051b11;\n --bs-info-bg-subtle: #032830;\n --bs-warning-bg-subtle: #332701;\n --bs-danger-bg-subtle: #2c0b0e;\n --bs-light-bg-subtle: #343a40;\n --bs-dark-bg-subtle: #1a1d20;\n --bs-primary-border-subtle: #084298;\n --bs-secondary-border-subtle: #41464b;\n --bs-success-border-subtle: #0f5132;\n --bs-info-border-subtle: #087990;\n --bs-warning-border-subtle: #997404;\n --bs-danger-border-subtle: #842029;\n --bs-light-border-subtle: #495057;\n --bs-dark-border-subtle: #343a40;\n --bs-heading-color: inherit;\n --bs-link-color: #6ea8fe;\n --bs-link-hover-color: #8bb9fe;\n --bs-link-color-rgb: 110, 168, 254;\n --bs-link-hover-color-rgb: 139, 185, 254;\n --bs-code-color: #e685b5;\n --bs-highlight-color: #dee2e6;\n --bs-highlight-bg: #664d03;\n --bs-border-color: #495057;\n --bs-border-color-translucent: rgba(255, 255, 255, 0.15);\n --bs-form-valid-color: #75b798;\n --bs-form-valid-border-color: #75b798;\n --bs-form-invalid-color: #ea868f;\n --bs-form-invalid-border-color: #ea868f;\n}\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n :root {\n scroll-behavior: smooth;\n }\n}\n\nbody {\n margin: 0;\n font-family: var(--bs-body-font-family);\n font-size: var(--bs-body-font-size);\n font-weight: var(--bs-body-font-weight);\n line-height: var(--bs-body-line-height);\n color: var(--bs-body-color);\n text-align: var(--bs-body-text-align);\n background-color: var(--bs-body-bg);\n -webkit-text-size-adjust: 100%;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n}\n\nhr {\n margin: 1rem 0;\n color: inherit;\n border: 0;\n border-top: var(--bs-border-width) solid;\n opacity: 0.25;\n}\n\nh6, h5, h4, h3, h2, h1 {\n margin-top: 0;\n margin-bottom: 0.5rem;\n font-weight: 500;\n line-height: 1.2;\n color: var(--bs-heading-color);\n}\n\nh1 {\n font-size: calc(1.375rem + 1.5vw);\n}\n@media (min-width: 1200px) {\n h1 {\n font-size: 2.5rem;\n }\n}\n\nh2 {\n font-size: calc(1.325rem + 0.9vw);\n}\n@media (min-width: 1200px) {\n h2 {\n font-size: 2rem;\n }\n}\n\nh3 {\n font-size: calc(1.3rem + 0.6vw);\n}\n@media (min-width: 1200px) {\n h3 {\n font-size: 1.75rem;\n }\n}\n\nh4 {\n font-size: calc(1.275rem + 0.3vw);\n}\n@media (min-width: 1200px) {\n h4 {\n font-size: 1.5rem;\n }\n}\n\nh5 {\n font-size: 1.25rem;\n}\n\nh6 {\n font-size: 1rem;\n}\n\np {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nabbr[title] {\n text-decoration: underline dotted;\n cursor: help;\n text-decoration-skip-ink: none;\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: 700;\n}\n\ndd {\n margin-bottom: 0.5rem;\n margin-left: 0;\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: bolder;\n}\n\nsmall {\n font-size: 0.875em;\n}\n\nmark {\n padding: 0.1875em;\n color: var(--bs-highlight-color);\n background-color: var(--bs-highlight-bg);\n}\n\nsub,\nsup {\n position: relative;\n font-size: 0.75em;\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\na {\n color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));\n text-decoration: underline;\n}\na:hover {\n --bs-link-color-rgb: var(--bs-link-hover-color-rgb);\n}\n\na:not([href]):not([class]), a:not([href]):not([class]):hover {\n color: inherit;\n text-decoration: none;\n}\n\npre,\ncode,\nkbd,\nsamp {\n font-family: var(--bs-font-monospace);\n font-size: 1em;\n}\n\npre {\n display: block;\n margin-top: 0;\n margin-bottom: 1rem;\n overflow: auto;\n font-size: 0.875em;\n}\npre code {\n font-size: inherit;\n color: inherit;\n word-break: normal;\n}\n\ncode {\n font-size: 0.875em;\n color: var(--bs-code-color);\n word-wrap: break-word;\n}\na > code {\n color: inherit;\n}\n\nkbd {\n padding: 0.1875rem 0.375rem;\n font-size: 0.875em;\n color: var(--bs-body-bg);\n background-color: var(--bs-body-color);\n border-radius: 0.25rem;\n}\nkbd kbd {\n padding: 0;\n font-size: 1em;\n}\n\nfigure {\n margin: 0 0 1rem;\n}\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: 0.5rem;\n padding-bottom: 0.5rem;\n color: var(--bs-secondary-color);\n text-align: left;\n}\n\nth {\n text-align: inherit;\n text-align: -webkit-match-parent;\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\nlabel {\n display: inline-block;\n}\n\nbutton {\n border-radius: 0;\n}\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0;\n font-family: inherit;\n font-size: inherit;\n line-height: inherit;\n}\n\nbutton,\nselect {\n text-transform: none;\n}\n\n[role=button] {\n cursor: pointer;\n}\n\nselect {\n word-wrap: normal;\n}\nselect:disabled {\n opacity: 1;\n}\n\n[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {\n display: none !important;\n}\n\nbutton,\n[type=button],\n[type=reset],\n[type=submit] {\n -webkit-appearance: button;\n}\nbutton:not(:disabled),\n[type=button]:not(:disabled),\n[type=reset]:not(:disabled),\n[type=submit]:not(:disabled) {\n cursor: pointer;\n}\n\n::-moz-focus-inner {\n padding: 0;\n border-style: none;\n}\n\ntextarea {\n resize: vertical;\n}\n\nfieldset {\n min-width: 0;\n padding: 0;\n margin: 0;\n border: 0;\n}\n\nlegend {\n float: left;\n width: 100%;\n padding: 0;\n margin-bottom: 0.5rem;\n font-size: calc(1.275rem + 0.3vw);\n line-height: inherit;\n}\n@media (min-width: 1200px) {\n legend {\n font-size: 1.5rem;\n }\n}\nlegend + * {\n clear: left;\n}\n\n::-webkit-datetime-edit-fields-wrapper,\n::-webkit-datetime-edit-text,\n::-webkit-datetime-edit-minute,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-year-field {\n padding: 0;\n}\n\n::-webkit-inner-spin-button {\n height: auto;\n}\n\n[type=search] {\n -webkit-appearance: textfield;\n outline-offset: -2px;\n}\n\n/* rtl:raw:\n[type=\"tel\"],\n[type=\"url\"],\n[type=\"email\"],\n[type=\"number\"] {\n direction: ltr;\n}\n*/\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n::-webkit-color-swatch-wrapper {\n padding: 0;\n}\n\n::file-selector-button {\n font: inherit;\n -webkit-appearance: button;\n}\n\noutput {\n display: inline-block;\n}\n\niframe {\n border: 0;\n}\n\nsummary {\n display: list-item;\n cursor: pointer;\n}\n\nprogress {\n vertical-align: baseline;\n}\n\n[hidden] {\n display: none !important;\n}\n\n/*# sourceMappingURL=bootstrap-reboot.css.map */\n","// scss-docs-start color-mode-mixin\n@mixin color-mode($mode: light, $root: false) {\n @if $color-mode-type == \"media-query\" {\n @if $root == true {\n @media (prefers-color-scheme: $mode) {\n :root {\n @content;\n }\n }\n } @else {\n @media (prefers-color-scheme: $mode) {\n @content;\n }\n }\n } @else {\n [data-bs-theme=\"#{$mode}\"] {\n @content;\n }\n }\n}\n// scss-docs-end color-mode-mixin\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box;\n}\n\n\n// Root\n//\n// Ability to the value of the root font sizes, affecting the value of `rem`.\n// null by default, thus nothing is generated.\n\n:root {\n @if $font-size-root != null {\n @include font-size(var(--#{$prefix}root-font-size));\n }\n\n @if $enable-smooth-scroll {\n @media (prefers-reduced-motion: no-preference) {\n scroll-behavior: smooth;\n }\n }\n}\n\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Prevent adjustments of font size after orientation changes in iOS.\n// 4. Change the default tap highlight to be completely transparent in iOS.\n\n// scss-docs-start reboot-body-rules\nbody {\n margin: 0; // 1\n font-family: var(--#{$prefix}body-font-family);\n @include font-size(var(--#{$prefix}body-font-size));\n font-weight: var(--#{$prefix}body-font-weight);\n line-height: var(--#{$prefix}body-line-height);\n color: var(--#{$prefix}body-color);\n text-align: var(--#{$prefix}body-text-align);\n background-color: var(--#{$prefix}body-bg); // 2\n -webkit-text-size-adjust: 100%; // 3\n -webkit-tap-highlight-color: rgba($black, 0); // 4\n}\n// scss-docs-end reboot-body-rules\n\n\n// Content grouping\n//\n// 1. Reset Firefox's gray color\n\nhr {\n margin: $hr-margin-y 0;\n color: $hr-color; // 1\n border: 0;\n border-top: $hr-border-width solid $hr-border-color;\n opacity: $hr-opacity;\n}\n\n\n// Typography\n//\n// 1. Remove top margins from headings\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n\n%heading {\n margin-top: 0; // 1\n margin-bottom: $headings-margin-bottom;\n font-family: $headings-font-family;\n font-style: $headings-font-style;\n font-weight: $headings-font-weight;\n line-height: $headings-line-height;\n color: var(--#{$prefix}heading-color);\n}\n\nh1 {\n @extend %heading;\n @include font-size($h1-font-size);\n}\n\nh2 {\n @extend %heading;\n @include font-size($h2-font-size);\n}\n\nh3 {\n @extend %heading;\n @include font-size($h3-font-size);\n}\n\nh4 {\n @extend %heading;\n @include font-size($h4-font-size);\n}\n\nh5 {\n @extend %heading;\n @include font-size($h5-font-size);\n}\n\nh6 {\n @extend %heading;\n @include font-size($h6-font-size);\n}\n\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\n\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n\n// Abbreviations\n//\n// 1. Add the correct text decoration in Chrome, Edge, Opera, and Safari.\n// 2. Add explicit cursor to indicate changed behavior.\n// 3. Prevent the text-decoration to be skipped.\n\nabbr[title] {\n text-decoration: underline dotted; // 1\n cursor: help; // 2\n text-decoration-skip-ink: none; // 3\n}\n\n\n// Address\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\n\n// Lists\n\nol,\nul {\n padding-left: 2rem;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\n// 1. Undo browser default\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // 1\n}\n\n\n// Blockquote\n\nblockquote {\n margin: 0 0 1rem;\n}\n\n\n// Strong\n//\n// Add the correct font weight in Chrome, Edge, and Safari\n\nb,\nstrong {\n font-weight: $font-weight-bolder;\n}\n\n\n// Small\n//\n// Add the correct font size in all browsers\n\nsmall {\n @include font-size($small-font-size);\n}\n\n\n// Mark\n\nmark {\n padding: $mark-padding;\n color: var(--#{$prefix}highlight-color);\n background-color: var(--#{$prefix}highlight-bg);\n}\n\n\n// Sub and Sup\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n\nsub,\nsup {\n position: relative;\n @include font-size($sub-sup-font-size);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n// Links\n\na {\n color: rgba(var(--#{$prefix}link-color-rgb), var(--#{$prefix}link-opacity, 1));\n text-decoration: $link-decoration;\n\n &:hover {\n --#{$prefix}link-color-rgb: var(--#{$prefix}link-hover-color-rgb);\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n &,\n &:hover {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n// Code\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-code;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\n// 1. Remove browser default top margin\n// 2. Reset browser default of `1em` to use `rem`s\n// 3. Don't allow content to break outside\n\npre {\n display: block;\n margin-top: 0; // 1\n margin-bottom: 1rem; // 2\n overflow: auto; // 3\n @include font-size($code-font-size);\n color: $pre-color;\n\n // Account for some code outputs that place code tags in pre tags\n code {\n @include font-size(inherit);\n color: inherit;\n word-break: normal;\n }\n}\n\ncode {\n @include font-size($code-font-size);\n color: var(--#{$prefix}code-color);\n word-wrap: break-word;\n\n // Streamline the style when inside anchors to avoid broken underline and more\n a > & {\n color: inherit;\n }\n}\n\nkbd {\n padding: $kbd-padding-y $kbd-padding-x;\n @include font-size($kbd-font-size);\n color: $kbd-color;\n background-color: $kbd-bg;\n @include border-radius($border-radius-sm);\n\n kbd {\n padding: 0;\n @include font-size(1em);\n font-weight: $nested-kbd-font-weight;\n }\n}\n\n\n// Figures\n//\n// Apply a consistent margin strategy (matches our type styles).\n\nfigure {\n margin: 0 0 1rem;\n}\n\n\n// Images and content\n\nimg,\nsvg {\n vertical-align: middle;\n}\n\n\n// Tables\n//\n// Prevent double borders\n\ntable {\n caption-side: bottom;\n border-collapse: collapse;\n}\n\ncaption {\n padding-top: $table-cell-padding-y;\n padding-bottom: $table-cell-padding-y;\n color: $table-caption-color;\n text-align: left;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\nthead,\ntbody,\ntfoot,\ntr,\ntd,\nth {\n border-color: inherit;\n border-style: solid;\n border-width: 0;\n}\n\n\n// Forms\n//\n// 1. Allow labels to use `margin` for spacing.\n\nlabel {\n display: inline-block; // 1\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n// See https://github.com/twbs/bootstrap/issues/24093\n\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\n// 1. Remove the margin in Firefox and Safari\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // 1\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\n// Remove the inheritance of text transform in Firefox\nbutton,\nselect {\n text-transform: none;\n}\n// Set the cursor for non-` +

+ The session has been paused by the server. +

+ +

+ Failed to resume the session.
Please reload the page. +

+ + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 00000000..3ad3773f --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.js b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 00000000..e52a190b --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + location.reload(); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor new file mode 100644 index 00000000..b1720a39 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor @@ -0,0 +1,103 @@ +๏ปฟ@page "/" +@page "/login" +@attribute [UAuthLoginPage] +@inherits UAuthHubPageBase + +@implements IDisposable +@using CodeBeam.UltimateAuth.Client.Infrastructure +@using CodeBeam.UltimateAuth.Client.Options +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Server.Services +@using CodeBeam.UltimateAuth.Server.Stores +@using Microsoft.Extensions.Options +@inject IUAuthClient UAuthClient +@inject IAuthStore AuthStore +@inject IHubFlowService HubFlowService +@inject IPkceService PkceService +@inject IHubCredentialResolver HubCredentialResolver +@inject IClientStorage BrowserStorage +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService +@inject IOptions Options + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..c3258e6a --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/Home.razor.cs @@ -0,0 +1,261 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; + +public partial class Home +{ + private string? _username; + private string? _password; + + private UAuthClientProductInfo? _productInfo; + private UAuthLoginForm _loginForm = null!; + + private CancellationTokenSource? _lockoutCts; + private PeriodicTimer? _lockoutTimer; + private DateTimeOffset? _lockoutUntil; + private TimeSpan _remaining; + private bool _isLocked; + private DateTimeOffset? _lockoutStartedAt; + private TimeSpan _lockoutDuration; + private double _progressPercent; + private int? _remainingAttempts = null; + private bool _errorHandled; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (string.IsNullOrWhiteSpace(HubKey)) + return; + + if (HubState is null || !HubState.Exists) + { + return; + } + + if (HubState.IsExpired) + { + await ContinuePkceAsync(); + return; + } + + if (HubState.Error != null && !_errorHandled) + { + _errorHandled = true; + Snackbar.Add(ResolveErrorMessage(HubState.Error), Severity.Error); + await ContinuePkceAsync(); + + if (HubSessionId.TryParse(HubKey, out var hubSessionId)) + { + await ReloadState(); + } + + await _loginForm.ReloadAsync(); + + StateHasChanged(); + } + } + + // For testing & debugging + private async Task ProgrammaticPkceLogin() + { + var hub = HubState; + + if (hub is null) + return; + + if (!HubSessionId.TryParse(HubKey, out var hubSessionId)) + return; + + var credentials = await HubCredentialResolver.ResolveAsync(hubSessionId); + + var request = new PkceCompleteRequest + { + Identifier = "admin", + Secret = "admin", + AuthorizationCode = credentials?.AuthorizationCode ?? string.Empty, + CodeVerifier = credentials?.CodeVerifier ?? string.Empty, + ReturnUrl = HubState?.ReturnUrl ?? string.Empty, + HubSessionId = HubState?.HubSessionId.Value ?? hubSessionId.Value, + }; + + await UAuthClient.Flows.TryCompletePkceLoginAsync(request, UAuthSubmitMode.TryAndCommit); + } + + private async Task HandleLoginResult(IUAuthTryResult result) + { + if (result is TryPkceLoginResult pkce) + { + if (!result.Success) + { + if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = result.RemainingAttempts; + + ShowLoginError(result.Reason, result.RemainingAttempts); + await ContinuePkceAsync(); + } + } + } + + private HubCredentials? _pkce; + + private async Task ContinuePkceAsync() + { + if (string.IsNullOrWhiteSpace(HubKey)) + return; + + var key = new AuthArtifactKey(HubKey); + var artifact = await AuthStore.GetAsync(key) as HubFlowArtifact; + + if (artifact is null) + return; + + _pkce = await PkceService.RefreshAsync(artifact); + await HubFlowService.ContinuePkceAsync(HubKey, _pkce.AuthorizationCode, _pkce.CodeVerifier); + } + + private async Task StartNewPkceAsync() + { + var returnUrl = await ResolveReturnUrlAsync(); + await UAuthClient.Flows.BeginPkceAsync(returnUrl); + } + + private async Task ResolveReturnUrlAsync() + { + var fromContext = HubState?.ReturnUrl; + if (!string.IsNullOrWhiteSpace(fromContext)) + return fromContext; + + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue("return_url", out var ru) && !string.IsNullOrWhiteSpace(ru)) + return ru!; + + if (query.TryGetValue("hub", out var hubKey) && !string.IsNullOrWhiteSpace(hubKey)) + { + var artifact = await AuthStore.GetAsync(new AuthArtifactKey(hubKey!)); + if (artifact is HubFlowArtifact flow && !string.IsNullOrWhiteSpace(flow.ReturnUrl)) + return flow.ReturnUrl!; + } + + return Nav.Uri; + } + + private async void StartCountdown() + { + if (_lockoutUntil is null) + return; + + _isLocked = true; + _lockoutStartedAt = DateTimeOffset.UtcNow; + _lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow; + UpdateRemaining(); + + _lockoutCts?.Cancel(); + _lockoutCts = new CancellationTokenSource(); + + _lockoutTimer?.Dispose(); + _lockoutTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + while (await _lockoutTimer.WaitForNextTickAsync(_lockoutCts.Token)) + { + UpdateRemaining(); + + if (_remaining <= TimeSpan.Zero) + { + ResetLockoutState(); + await InvokeAsync(StateHasChanged); + break; + } + + await InvokeAsync(StateHasChanged); + } + } + catch (OperationCanceledException) + { + + } + } + + private void ResetLockoutState() + { + _isLocked = false; + _lockoutUntil = null; + _progressPercent = 0; + _remainingAttempts = null; + } + + private void UpdateRemaining() + { + if (_lockoutUntil is null || _lockoutStartedAt is null) + return; + + var now = DateTimeOffset.UtcNow; + + _remaining = _lockoutUntil.Value - now; + + if (_remaining <= TimeSpan.Zero) + { + _remaining = TimeSpan.Zero; + return; + } + + var elapsed = now - _lockoutStartedAt.Value; + + if (_lockoutDuration.TotalSeconds > 0) + { + var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100); + _progressPercent = Math.Max(0, percent); + } + } + + private void ShowLoginError(AuthFailureReason? reason, int? remainingAttempts) + { + string message = reason switch + { + AuthFailureReason.InvalidCredentials when remainingAttempts is > 0 + => $"Invalid username or password. {remainingAttempts} attempt(s) remaining.", + + AuthFailureReason.InvalidCredentials + => "Invalid username or password.", + + AuthFailureReason.RequiresMfa + => "Multi-factor authentication required.", + + AuthFailureReason.LockedOut + => "Your account is locked.", + + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private string ResolveErrorMessage(HubErrorCode? errorCode) + { + if (errorCode == HubErrorCode.InvalidCredentials) + { + return "Invalid credentials."; + } + + return "Failed attempt."; + } + +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor new file mode 100644 index 00000000..2c0e9b77 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor @@ -0,0 +1,27 @@ +๏ปฟ@inject NavigationManager Nav + + + + + + + Access Denied + + + You donโ€™t have permission to view this page. + If you think this is a mistake, sign in with a different account or request access. + + + + Sign In + Go Back + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor.cs new file mode 100644 index 00000000..4e750030 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotAuthorized.razor.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages; + +public partial class NotAuthorized +{ + private string LoginHref + { + get + { + var returnUrl = Uri.EscapeDataString(Nav.ToBaseRelativePath(Nav.Uri)); + return $"/login?returnUrl=/{returnUrl}"; + } + } + + private void GoBack() => Nav.NavigateTo("/", replace: false); +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotFound.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotFound.razor new file mode 100644 index 00000000..917ada1d --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Pages/NotFound.razor @@ -0,0 +1,5 @@ +๏ปฟ@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

\ No newline at end of file diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor new file mode 100644 index 00000000..22f83d10 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/Routes.razor @@ -0,0 +1,73 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Pages +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + @* Advanced: you can fully control routing by providing your own Router *@ + @* + + + + + + + + + + + + + + + + *@ + + +@code { + private async Task HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } + + #region DarkMode + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + private void OnThemeChanged() + { + InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await DarkModeManager.InitializeAsync(); + StateHasChanged(); + } + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } + + #endregion +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor new file mode 100644 index 00000000..f530884f --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Components/_Imports.razor @@ -0,0 +1,18 @@ +๏ปฟ@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.JSInterop +@using CodeBeam.UltimateAuth.Sample.UAuthHub +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components +@using CodeBeam.UltimateAuth.Sample.UAuthHub.Components.Layout +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Blazor + +@using MudBlazor +@using MudExtensions diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Infrastructure/DarkModeManager.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Infrastructure/DarkModeManager.cs new file mode 100644 index 00000000..95b63347 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Infrastructure/DarkModeManager.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure; + +public sealed class DarkModeManager +{ + private const string StorageKey = "uauth:theme:dark"; + + private readonly IClientStorage _storage; + + public DarkModeManager(IClientStorage storage) + { + _storage = storage; + } + + public async Task InitializeAsync() + { + var value = await _storage.GetAsync(StorageScope.Local, StorageKey); + + if (bool.TryParse(value, out var parsed)) + IsDarkMode = parsed; + } + + public bool IsDarkMode { get; set; } + + public event Action? Changed; + + public async Task ToggleAsync() + { + IsDarkMode = !IsDarkMode; + + await _storage.SetAsync(StorageScope.Local, StorageKey, IsDarkMode.ToString()); + Changed?.Invoke(); + } + + public void Set(bool value) + { + if (IsDarkMode == value) + return; + + IsDarkMode = value; + Changed?.Invoke(); + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs new file mode 100644 index 00000000..9108573e --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Program.cs @@ -0,0 +1,83 @@ +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Sample.Seed.Extensions; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Components; +using CodeBeam.UltimateAuth.Sample.UAuthHub.Infrastructure; +using CodeBeam.UltimateAuth.Server.Extensions; +using MudBlazor.Services; +using MudExtensions.Services; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); + +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); +builder.Services.AddMudExtensions(); + +builder.Services.AddScoped(); + +builder.Services.AddUltimateAuthServer(o => { + o.Diagnostics.EnableRefreshDetails = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); + //o.Session.Lifetime = TimeSpan.FromSeconds(32); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); + //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); + //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.AllowMultipleUsernames = true; +}) + .AddUltimateAuthInMemory() + .AddUAuthHub(o => o.AllowedClientOrigins.Add("https://localhost:6130")); // Client sample's URL + +builder.Services.AddUltimateAuthSampleSeed(); + +builder.Services.AddUltimateAuthClientBlazor(o => +{ + //o.Refresh.Interval = TimeSpan.FromSeconds(5); + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; +}); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} +else +{ + app.MapOpenApi(); + app.MapScalarApiReference(); + + using var scope = app.Services.CreateScope(); + var seedRunner = scope.ServiceProvider.GetRequiredService(); + await seedRunner.RunAsync(null); +} + +app.UseHttpsRedirection(); + +app.UseUltimateAuthWithAspNetCore(); +app.UseAntiforgery(); + +app.MapUltimateAuthEndpoints(); +app.MapUAuthHub(); +app.MapStaticAssets(); + +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); + +app.Run(); diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Properties/launchSettings.json b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Properties/launchSettings.json new file mode 100644 index 00000000..4d8b0eef --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:6111", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:6110;http://localhost:6111", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.Development.json b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.json b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/UltimateAuth-Logo.png b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/UltimateAuth-Logo.png new file mode 100644 index 00000000..5b7282f1 Binary files /dev/null and b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/UltimateAuth-Logo.png differ diff --git a/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css new file mode 100644 index 00000000..17fcfd6a --- /dev/null +++ b/samples/UAuthHub/CodeBeam.UltimateAuth.Sample.UAuthHub/wwwroot/app.css @@ -0,0 +1,148 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.uauth-stack { + min-height: 60vh; + max-height: calc(100vh - var(--mud-appbar-height)); + width: 30vw; + min-width: 300px; +} + +.uauth-menu-popover { + width: 300px; +} + +.uauth-login-paper { + min-height: 70vh; +} + + .uauth-login-paper.mud-theme-primary { + background: linear-gradient(145deg, var(--mud-palette-primary), rgba(0, 0, 0, 0.85) ); + color: white; + } + + .uauth-login-paper.mud-theme-secondary { + background: linear-gradient(145deg, var(--mud-palette-secondary), rgba(0, 0, 0, 0.85) ); + color: white; + } + +.uauth-brand-glow { + filter: drop-shadow(0 0 25px rgba(255,255,255,0.15)); +} + +.uauth-logo-slide { + animation: uauth-logo-float 30s ease-in-out infinite; +} + +.uauth-text-transform-none .mud-button { + text-transform: none; +} + +.uauth-dialog { + height: 68vh; + max-height: 68vh; + overflow: auto; +} + +.text-secondary { + color: var(--mud-palette-text-secondary); +} + +.uauth-blur { + backdrop-filter: blur(10px); +} + +.uauth-blur-slight { + backdrop-filter: blur(4px); +} + +@keyframes uauth-logo-float { + 0% { + transform: translateY(0) rotateY(0); + } + + 10% { + transform: translateY(0) rotateY(0); + } + + 15% { + transform: translateY(200px) rotateY(360deg); + } + + 35% { + transform: translateY(200px) rotateY(360deg); + } + + 40% { + transform: translateY(200px) rotateY(720deg); + } + + 60% { + transform: translateY(200px) rotateY(720deg); + } + + 65% { + transform: translateY(0) rotateY(360deg); + } + + 85% { + transform: translateY(0) rotateY(360deg); + } + + 90% { + transform: translateY(0) rotateY(0); + } + + 100% { + transform: translateY(0) rotateY(0); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor new file mode 100644 index 00000000..2806b7d3 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor @@ -0,0 +1,19 @@ +๏ปฟ@namespace CodeBeam.UltimateAuth.Sample +@inherits ComponentBase + + + + @if (Variant == UAuthLogoVariant.Brand) + { + + + + } + else + { + + + + } + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor.cs new file mode 100644 index 00000000..030d9b66 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogo.razor.cs @@ -0,0 +1,54 @@ +๏ปฟusing Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace CodeBeam.UltimateAuth.Sample; + +public partial class UAuthLogo : ComponentBase +{ + [Parameter] public UAuthLogoVariant Variant { get; set; } = UAuthLogoVariant.Brand; + + [Parameter] public int Size { get; set; } = 32; + + [Parameter] public string? ShieldColor { get; set; } = "#00072d"; + [Parameter] public string? KeyColor { get; set; } = "#f6f5ae"; + + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } + + private string BuildStyle() + { + if (Variant == UAuthLogoVariant.Mono) + return $"color: {KeyColor}; {Style}"; + + return Style ?? ""; + } + + protected string KeyPath => @" +M120.43,39.44H79.57A11.67,11.67,0,0,0,67.9,51.11V77.37 +A11.67,11.67,0,0,0,79.57,89H90.51l3.89,3.9v5.32l-3.8,3.81v81.41H99 +v-5.33h13.69V169H108.1v-3.8H99C99,150.76,111.9,153,111.9,153 +V99.79h-8V93.32L108.19,89h12.24 +A11.67,11.67,0,0,0,132.1,77.37V51.11 +A11.67,11.67,0,0,0,120.43,39.44Z + +M79.57,48.19h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.84a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.84a2.91,2.91 0 0 1 2.91,-2.92Z + +M79.57,68.62h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.83a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.83a2.91,2.91 0 0 1 2.91,-2.92Z + +M114.59,48.19h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.84a2.91,2.91 0 0 1 -2.91,2.91 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.91 +v-5.84a2.92,2.92 0 0 1 2.92,-2.92Z + +M114.59,68.62h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.83a2.91,2.91 0 0 1 -2.91,2.92 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.92 +v-5.83a2.92,2.92 0 0 1 2.92,-2.92Z +"; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogoVariant.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogoVariant.cs new file mode 100644 index 00000000..fe3be220 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Brand/UAuthLogoVariant.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample; + +public enum UAuthLogoVariant +{ + Brand, + Mono +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj new file mode 100644 index 00000000..f49ac077 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + false + true + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Common/UAuthDialog.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Common/UAuthDialog.cs new file mode 100644 index 00000000..2183c80d --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Common/UAuthDialog.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Common; + +public static class UAuthDialog +{ + public static DialogParameters GetDialogParameters(UAuthState state, UserKey? userKey = null) + { + DialogParameters parameters = new DialogParameters(); + parameters.Add("AuthState", state); + if (userKey != null ) + { + parameters.Add("UserKey", userKey); + } + return parameters; + } + + public static DialogOptions GetDialogOptions(MaxWidth maxWidth = MaxWidth.Medium) + { + return new DialogOptions + { + MaxWidth = maxWidth, + FullWidth = true, + CloseButton = true + }; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor new file mode 100644 index 00000000..6acc099e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/App.razor @@ -0,0 +1,29 @@ +๏ปฟ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Custom/UAuthPageComponent.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Custom/UAuthPageComponent.razor new file mode 100644 index 00000000..5af543e4 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Custom/UAuthPageComponent.razor @@ -0,0 +1,10 @@ +๏ปฟ + + @ChildContent + + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor new file mode 100644 index 00000000..0c91e45c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor @@ -0,0 +1,23 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + Suspend Account + + + + Delete Account + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor.cs new file mode 100644 index 00000000..195f1fbd --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/AccountStatusDialog.razor.cs @@ -0,0 +1,77 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class AccountStatusDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task SuspendAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to suspend your account.

+ You can still active your account later. + """, + yesText: "Suspend", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Suspend process cancelled.", Severity.Info); + return; + } + + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + } + } + + private async Task DeleteAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to delete your account.

+ This action can't be undone.

+ (Actually it is, admin can handle soft deleted accounts.) + """, + yesText: "Delete", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Deletion cancelled.", Severity.Info); + return; + } + + var result = await UAuthClient.Users.DeleteMeAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Your account deleted successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor new file mode 100644 index 00000000..9a514935 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor @@ -0,0 +1,27 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Create User + + + + + + + + + + + + + + + + Cancel + Create + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor.cs new file mode 100644 index 00000000..820b1119 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CreateUserDialog.razor.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class CreateUserDialog +{ + private MudForm _form = null!; + private string? _username; + private string? _email; + private string? _password; + private string? _passwordCheck; + private string? _displayName; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + private async Task CreateUserAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + if (_password != _passwordCheck) + { + Snackbar.Add("Passwords don't match.", Severity.Error); + return; + } + + var request = new CreateUserRequest + { + UserName = _username, + Email = _email, + DisplayName = _displayName, + Password = _password + }; + + var result = await UAuthClient.Users.CreateAsAdminAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "User creation failed.", Severity.Error); + return; + } + + Snackbar.Add("User created successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + + private string PasswordMatch(string? arg) => _password != arg ? "Passwords don't match." : string.Empty; + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor new file mode 100644 index 00000000..660b7c3a --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor @@ -0,0 +1,51 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Credential Management + User: @AuthState?.Identity?.DisplayName + + + + + @if (UserKey == null) + { + + + + } + else + { + + + Administrators can directly assign passwords to users. + However, using the credential reset flow is generally recommended for better security and auditability. + + + } + + + + + + + + + + + @(UserKey is null ? "Change Password" : "Set Password") + + + + + + Cancel + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs new file mode 100644 index 00000000..5f419abf --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/CredentialDialog.razor.cs @@ -0,0 +1,92 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class CredentialDialog +{ + private MudForm _form = null!; + private string? _oldPassword; + private string? _newPassword; + private string? _newPasswordCheck; + private bool _passwordMode1 = false; + private bool _passwordMode2 = false; + private bool _passwordMode3 = true; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + private async Task ChangePasswordAsync() + { + if (_form is null) + return; + + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Form is not valid.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("New password and check do not match", Severity.Error); + return; + } + + ChangeCredentialRequest request; + + if (UserKey is null) + { + request = new ChangeCredentialRequest + { + CurrentSecret = _oldPassword!, + NewSecret = _newPassword! + }; + } + else + { + request = new ChangeCredentialRequest + { + NewSecret = _newPassword! + }; + } + + UAuthResult result; + if (UserKey is null) + { + result = await UAuthClient.Credentials.ChangeMyAsync(request); + } + else + { + result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Password changed successfully", Severity.Success); + _oldPassword = null; + _newPassword = null; + _newPasswordCheck = null; + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.ErrorText ?? "An error occurred while changing password", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor new file mode 100644 index 00000000..0d631533 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor @@ -0,0 +1,106 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + + + + Identifiers + + + + + + + + + + + + + + + + + + + + + + + + + @if (context.Item.IsPrimary) + { + + + + } + else + { + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add + + + + + + + + Cancel + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor.cs new file mode 100644 index 00000000..67a76cff --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/IdentifierDialog.razor.cs @@ -0,0 +1,309 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class IdentifierDialog +{ + private MudDataGrid? _grid; + private UserIdentifierType _newIdentifierType; + private string? _newIdentifierValue; + private bool _newIdentifierPrimary; + private bool _loading = false; + private bool _reloadQueued; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Identifiers.GetMyAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + StateHasChanged(); + } + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + UAuthResult> res; + + if (UserKey is null) + { + res = await UAuthClient.Identifiers.GetMyAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserAsync(UserKey.Value, req); + } + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task CommittedItemChanges(UserIdentifierInfo item) + { + UpdateUserIdentifierRequest updateRequest = new() + { + Id = item.Id, + NewValue = item.Value + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier updated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to update identifier.", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task AddNewIdentifier() + { + if (string.IsNullOrEmpty(_newIdentifierValue)) + { + Snackbar.Add("Value cannot be empty", Severity.Warning); + return; + } + + AddUserIdentifierRequest request = new() + { + Type = _newIdentifierType, + Value = _newIdentifierValue, + IsPrimary = _newIdentifierPrimary + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.AddMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier added successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier.", Severity.Error); + } + } + + private async Task VerifyAsync(Guid id) + { + var demoInfo = await DialogService.ShowMessageBoxAsync( + title: "Demo Verification", + markupMessage: (MarkupString) + """ + This is a demo action.

+ In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. + This will only mark the identifier as verified in UltimateAuth. + """, + yesText: "Verify", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (demoInfo != true) + { + Snackbar.Add("Verification cancelled", Severity.Info); + return; + } + + VerifyUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifyMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier verified successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier.", Severity.Error); + } + } + + private async Task SetPrimaryAsync(Guid id) + { + SetPrimaryUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier set successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier.", Severity.Error); + } + } + + private async Task UnsetPrimaryAsync(Guid id) + { + UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier unset successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier.", Severity.Error); + } + } + + private async Task DeleteIdentifier(Guid id) + { + DeleteUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier deleted successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier.", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor new file mode 100644 index 00000000..8e0df863 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor @@ -0,0 +1,46 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using System.Reflection + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Role Permissions + @Role.Name + + + + @* For Debug *@ + @* Current Permissions: @string.Join(", ", Role.Permissions) *@ + + @foreach (var group in _groups) + { + + + + + @group.Name (@group.Items.Count(x => x.Selected)/@group.Items.Count) + + + + + @foreach (var perm in group.Items) + { + + + + } + + + + } + + + + + Cancel + Save + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor.cs new file mode 100644 index 00000000..0e755f76 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/PermissionDialog.razor.cs @@ -0,0 +1,120 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class PermissionDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public RoleInfo Role { get; set; } = default!; + + private List _groups = new(); + + protected override void OnInitialized() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + _groups = catalog + .GroupBy(p => p.Split('.')[0]) + .Select(g => new PermissionGroup + { + Name = g.Key, + Items = g.Select(p => new PermissionItem + { + Value = p, + Selected = selected.Contains(p) + }).ToList() + }) + .OrderBy(x => x.Name) + .ToList(); + } + + private void ToggleGroup(PermissionGroup group, bool value) + { + foreach (var item in group.Items) + item.Selected = value; + } + + private void TogglePermission(PermissionItem item, bool value) + { + item.Selected = value; + } + + private bool? GetGroupState(PermissionGroup group) + { + var selected = group.Items.Count(x => x.Selected); + + if (selected == 0) + return false; + + if (selected == group.Items.Count) + return true; + + return null; + } + + private async Task Save() + { + var permissions = _groups.SelectMany(g => g.Items).Where(x => x.Selected).Select(x => Permission.From(x.Value)).ToList(); + + var req = new SetRolePermissionsRequest + { + RoleId = Role.Id, + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); + + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "Failed to update permissions", Severity.Error); + return; + } + + var result2 = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name }); + if (result2.Value?.Items is not null) + { + Role = result2.Value.Items.First(); + } + + Snackbar.Add("Permissions updated", Severity.Success); + RefreshUI(); + } + + private void RefreshUI() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + foreach (var group in _groups) + { + foreach (var item in group.Items) + { + item.Selected = selected.Contains(item.Value); + } + } + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private class PermissionGroup + { + public string Name { get; set; } = ""; + public List Items { get; set; } = new(); + } + + private class PermissionItem + { + public string Value { get; set; } = ""; + public bool Selected { get; set; } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor new file mode 100644 index 00000000..d09fcfa0 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor @@ -0,0 +1,94 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + + + + + + Name + + + + + + + + + + + + + + + + + + + Personal + + + + + + + + + + + + + + + + + + + Localization + + + + + + + + + + + @foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + @tz.Id - @tz.DisplayName + } + + + + + + + + + + + + Cancel + Save + + 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 new file mode 100644 index 00000000..955e8e98 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ProfileDialog.razor.cs @@ -0,0 +1,114 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class ProfileDialog +{ + private MudForm? _form; + private string? _firstName; + private string? _lastName; + private string? _displayName; + private DateTime? _birthDate; + private string? _gender; + private string? _bio; + private string? _language; + private string? _timeZone; + private string? _culture; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Users.GetMeAsync(); + } + else + { + result = await UAuthClient.Users.GetUserAsync(UserKey.Value); + } + + if (result.IsSuccess && result.Value is not null) + { + var p = result.Value; + + _firstName = p.FirstName; + _lastName = p.LastName; + _displayName = p.DisplayName; + + _gender = p.Gender; + _birthDate = p.BirthDate?.ToDateTime(TimeOnly.MinValue); + _bio = p.Bio; + + _language = p.Language; + _timeZone = p.TimeZone; + _culture = p.Culture; + } + } + + private async Task SaveAsync() + { + if (AuthState is null || AuthState.Identity is null) + { + Snackbar.Add("No AuthState found.", Severity.Error); + return; + } + + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + return; + } + + var request = new UpdateProfileRequest + { + FirstName = _firstName, + LastName = _lastName, + DisplayName = _displayName, + BirthDate = _birthDate.HasValue ? DateOnly.FromDateTime(_birthDate.Value) : null, + Gender = _gender, + Bio = _bio, + Language = _language, + TimeZone = _timeZone, + Culture = _culture + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Users.UpdateMeAsync(request); + } + else + { + result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to update profile", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor new file mode 100644 index 00000000..06a515aa --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor @@ -0,0 +1,38 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Reset Credential + + + + + + This is a demonstration of how to implement a credential reset flow. + In a production application, you should use reset token or code in email, SMS etc. verification steps. + + + Reset request always returns ok even with not found users due to security reasons. + + + Request Reset + @if (_resetRequested) + { + Your reset code is: (Copy it before next step) + @_resetCode + Use Reset Code + } + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs new file mode 100644 index 00000000..a819f039 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/ResetDialog.razor.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class ResetDialog +{ + private bool _resetRequested = false; + private string? _resetCode; + private string? _identifier; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task RequestResetAsync() + { + var request = new BeginResetCredentialRequest + { + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code, + Identifier = _identifier ?? string.Empty + }; + + var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); + return; + } + + _resetCode = result.Value.Token; + _resetRequested = true; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor new file mode 100644 index 00000000..b78db16f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor @@ -0,0 +1,81 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Role Management + Manage system roles + + + + + + + + Roles + + + + + + + + + + + @GetPermissionCount(context.Item) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create + + + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor.cs new file mode 100644 index 00000000..931687d8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/RoleDialog.razor.cs @@ -0,0 +1,164 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class RoleDialog +{ + private MudDataGrid? _grid; + private bool _loading; + private string? _newRoleName; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new RoleQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Authorization.QueryRolesAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.ErrorText ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task CommittedItemChanges(RoleInfo role) + { + var req = new RenameRoleRequest + { + Id = role.Id, + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Rename failed", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task CreateRole() + { + if (string.IsNullOrWhiteSpace(_newRoleName)) + { + Snackbar.Add("Role name required.", Severity.Warning); + return; + } + + var req = new CreateRoleRequest + { + Name = _newRoleName + }; + + var res = await UAuthClient.Authorization.CreateRoleAsync(req); + + if (res.IsSuccess) + { + Snackbar.Add("Role created.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(res.ErrorText ?? "Creation failed.", Severity.Error); + } + } + + private async Task DeleteRole(RoleId roleId) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Delete role", + "Are you sure?", + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + return; + + var req = new DeleteRoleRequest() { Id = roleId }; + var result = await UAuthClient.Authorization.DeleteRoleAsync(req); + + if (result.IsSuccess) + { + Snackbar.Add($"Role deleted, assignments removed from {result.Value?.RemovedAssignments.ToString() ?? "unknown"} users.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Deletion failed.", Severity.Error); + } + } + + private async Task EditPermissions(RoleInfo role) + { + var dialog = await DialogService.ShowAsync( + "Edit Permissions", + new DialogParameters + { + { nameof(PermissionDialog.Role), role } + }, + new DialogOptions + { + CloseButton = true, + MaxWidth = MaxWidth.Large, + FullWidth = true + }); + + var result = await dialog.Result; + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + _loading = true; + await Task.Delay(300); + if (_grid is null) + return; + + await _grid.ReloadServerData(); + _loading = false; + } + + private int GetPermissionCount(RoleInfo role) + { + var expanded = PermissionExpander.Expand(role.Permissions, UAuthPermissionCatalog.GetAdminPermissions()); + return expanded.Count; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor new file mode 100644 index 00000000..8ecf2a15 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor @@ -0,0 +1,217 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Session Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + @if (_chainDetail is not null) + { + + + + Device Details + + + + @if (!_chainDetail.IsRevoked) + { + + Revoke Device + + } + + + + + + + Device Type + @_chainDetail.DeviceType + + + + Platform + @_chainDetail.Platform + + + + Operating System + @_chainDetail.OperatingSystem + + + + Browser + @_chainDetail.Browser + + + + Created + @_chainDetail.CreatedAt.ToLocalTime() + + + + Last Seen + @_chainDetail.LastSeenAt.ToLocalTime() + + + + State + + @_chainDetail.State + + + + + Active Session + @_chainDetail.ActiveSessionId + + + + Rotation Count + @_chainDetail.RotationCount + + + + Touch Count + @_chainDetail.TouchCount + + + + + + Session History + + + + Session Id + Created + Expires + Status + + + + @context.SessionId + @context.CreatedAt.ToLocalTime() + @context.ExpiresAt.ToLocalTime() + + @if (context.IsRevoked) + { + Revoked + } + else + { + Active + } + + + + + } + else + { + + Logout All Devices + @if (UserKey == null) + { + Logout Other Devices + } + Revoke All Devices + @if (UserKey == null) + { + Revoke Other Devices + } + + + + Sessions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.ChainId + + + + Created At + @context.Item.CreatedAt + + + + Touch Count + @context.Item.TouchCount + + + + Rotation Count + @context.Item.RotationCount + + + + + + + + + } + + + Cancel + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor.cs new file mode 100644 index 00000000..fb86d0cf --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/SessionDialog.razor.cs @@ -0,0 +1,284 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class SessionDialog +{ + private MudDataGrid? _grid; + private bool _loading = false; + private bool _reloadQueued; + private SessionChainDetail? _chainDetail; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Sessions.GetMyChainsAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + StateHasChanged(); + } + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + UAuthResult> res; + + if (UserKey is null) + { + res = await UAuthClient.Sessions.GetMyChainsAsync(req); + } + else + { + res = await UAuthClient.Sessions.GetUserChainsAsync(UserKey.Value, req); + } + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task LogoutAllAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + if (UserKey is null) + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutDeviceAsync(SessionChainId chainId) + { + LogoutDeviceRequest request = new() { ChainId = chainId }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of device.", Severity.Success); + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeAllAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); + } + else + { + result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(UserKey.Value); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + + if (UserKey is null) + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeOthersAsync() + { + var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Revoked all other devices.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeChainAsync(SessionChainId chainId) + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); + } + else + { + result = await UAuthClient.Sessions.RevokeUserChainAsync(UserKey.Value, chainId); + } + + if (result.IsSuccess) + { + Snackbar.Add("Device revoked successfully.", Severity.Success); + + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task ShowChainDetailsAsync(SessionChainId chainId) + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); + } + else + { + result = await UAuthClient.Sessions.GetUserChainDetailAsync(UserKey.Value, chainId); + } + + if (result.IsSuccess) + { + _chainDetail = result.Value; + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to fetch chain details.", Severity.Error); + _chainDetail = null; + } + } + + private void ClearDetail() + { + _chainDetail = null; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor new file mode 100644 index 00000000..73fafa48 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor @@ -0,0 +1,75 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + @_user?.UserKey.Value + + + + + + + + Display Name + @_user?.DisplayName + + + + Username + @_user?.UserName + + + + Email + @_user?.PrimaryEmail + + + + Phone + @_user?.PrimaryPhone + + + + Created + @_user?.CreatedAt?.ToLocalTime() + + + + Status + @_user?.Status + + + @foreach (var s in Enum.GetValues()) + { + @s + } + + Change + + + + + + + + Management + + Sessions + Profile + Identifiers + Credentials + Roles + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor.cs new file mode 100644 index 00000000..8a1a76a3 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserDetailDialog.razor.cs @@ -0,0 +1,100 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Common; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class UserDetailDialog +{ + private UserView? _user; + private AdminAssignableUserStatus _status; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + var result = await UAuthClient.Users.GetUserAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; + } + } + + private async Task OpenSessions() + { + await DialogService.ShowAsync("Session Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenProfile() + { + await DialogService.ShowAsync("Profile Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenIdentifiers() + { + await DialogService.ShowAsync("Identifier Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenCredentials() + { + await DialogService.ShowAsync("Credentials", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenRoles() + { + await DialogService.ShowAsync("Roles", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task ChangeStatusAsync() + { + if (_user is null) + return; + + ChangeUserStatusAdminRequest request = new() + { + NewStatus = _status + }; + + var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status.ToUserStatus() }; + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + } + + private Color GetStatusColor(UserStatus? status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor new file mode 100644 index 00000000..6e754848 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor @@ -0,0 +1,49 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + + User Roles + UserKey: @UserKey.Value + + + + + Assigned Roles + + @if (_roles.Count == 0) + { + No roles assigned + } + + + @foreach (var role in _roles) + { + @role + } + + + + + Add Role + + + + @foreach (var role in _allRoles) + { + @role.Name + } + + + Add + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor.cs new file mode 100644 index 00000000..b1693a25 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UserRoleDialog.razor.cs @@ -0,0 +1,124 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class UserRoleDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } = default!; + + private List _roles = new(); + private List _allRoles = new(); + + private string? _selectedRole; + + protected override async Task OnInitializedAsync() + { + await LoadRoles(); + } + + private async Task LoadRoles() + { + var userRoles = await UAuthClient.Authorization.GetUserRolesAsync(UserKey); + + if (userRoles.IsSuccess && userRoles.Value != null) + _roles = userRoles.Value.Roles.Items.Select(x => x.Name).ToList(); + + var roles = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery + { + PageNumber = 1, + PageSize = 200 + }); + + if (roles.IsSuccess && roles.Value != null) + _allRoles = roles.Value.Items.ToList(); + } + + private async Task AddRole() + { + if (string.IsNullOrWhiteSpace(_selectedRole)) + return; + + var request = new AssignRoleRequest + { + UserKey = UserKey, + RoleName = _selectedRole + }; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + + _selectedRole = null; + } + + private async Task RemoveRole(string role) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Remove Role", + $"Remove {role} from user?", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + + if (role == "Admin") + { + var confirm2 = await DialogService.ShowMessageBoxAsync( + "Are You Sure", + "You are going to remove admin role. This action may cause the application unuseable.", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm2 != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + } + + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed.", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + } + + private void Close() => MudDialog.Close(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor new file mode 100644 index 00000000..5fba5e4c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor @@ -0,0 +1,85 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + Browse, create and manage users + + + + + + + + + + + + + + + + + Users + + New User + + + + + + + + + + + @context.Item.Status + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.UserKey.Value + + + + Created At + @context.Item.CreatedAt + + + + + + + + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs new file mode 100644 index 00000000..e6807ab6 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Dialogs/UsersDialog.razor.cs @@ -0,0 +1,176 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Common; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; + +public partial class UsersDialog +{ + private MudDataGrid? _grid; + private bool _loading; + private string? _search; + private bool _reloadQueued; + private UserStatus? _statusFilter; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task> LoadUsers(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new UserQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + Search = _search, + Status = _statusFilter, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Users.QueryAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.ErrorText ?? "Failed to load users.", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task OnStatusChanged(UserStatus? status) + { + _statusFilter = status; + await ReloadAsync(); + } + + private async Task OpenUser(UserKey userKey) + { + var dialog = await DialogService.ShowAsync("User", UAuthDialog.GetDialogParameters(AuthState, userKey), UAuthDialog.GetDialogOptions()); + await dialog.Result; + await ReloadAsync(); + } + + private async Task OpenCreateUser() + { + var dialog = await DialogService.ShowAsync( + "Create User", + new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseButton = true + }); + + var result = await dialog.Result; + + if (result?.Canceled == false) + await ReloadAsync(); + } + + private async Task DeleteUserAsync(UserSummary user) + { + var confirm = await DialogService.ShowMessageBoxAsync( + title: "Delete user", + markupMessage: (MarkupString)$""" + Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.PrimaryEmail ?? user.UserKey}? +

+ This operation is intended for admin usage. + """, + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (confirm != true) + return; + + var req = new DeleteUserRequest + { + Mode = DeleteMode.Soft + }; + + var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + + if (result.IsSuccess) + { + Snackbar.Add("User deleted successfully.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to delete user.", Severity.Error); + } + } + + private static Color GetStatusColor(UserStatus status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.SelfSuspended => Color.Warning, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..2e4a24bb --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor @@ -0,0 +1,65 @@ +๏ปฟ@inherits LayoutComponentBase +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject NavigationManager Nav + + + + + UltimateAuth + + Blazor Server EFCore Sample + + + + + + + + + +
+ + + @((state.Identity?.DisplayName ?? "?").Trim() is var n ? (n.Length >= 2 ? n[..2] : n[..1]) : "?") + + +
+
+ + + @state.Identity?.DisplayName + @string.Join(", ", state.Claims.Roles) + + + + + + + + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) + { + + + } + +
+
+ + + + +
+
+ + + @Body + +
+ + +
+ An unhandled error has occurred. + Reload + ๐Ÿ—™ +
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.cs new file mode 100644 index 00000000..8ae19aa7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.cs @@ -0,0 +1,130 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Infrastructure; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Layout; + +public partial class MainLayout +{ + [CascadingParameter] + public UAuthState UAuth { get; set; } = default!; + + [CascadingParameter] + public DarkModeManager DarkModeManager { get; set; } = default!; + + private async Task Refresh() + { + await UAuthClient.Flows.RefreshAsync(); + } + + private async Task Logout() + { + await UAuthClient.Flows.LogoutAsync(); + } + + private Color GetBadgeColor() + { + if (UAuth is null || !UAuth.IsAuthenticated) + return Color.Error; + + if (UAuth.IsStale) + return Color.Warning; + + var state = UAuth.Identity?.SessionState; + + if (state is null || state == SessionState.Active) + return Color.Success; + + if (state == SessionState.Invalid) + return Color.Error; + + return Color.Warning; + } + + private void HandleSignInClick() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + Nav.NavigateTo("/login?focus=1", replace: true, forceLoad: true); + return; + } + + GoToLoginWithReturn(); + } + + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active โ€ข Tenant: {result.Snapshot?.Identity?.Tenant.Value} โ€ข User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + + private void GoToLoginWithReturn() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + Nav.NavigateTo("/login", replace: true); + return; + } + + var current = Nav.ToBaseRelativePath(uri.ToString()); + if (string.IsNullOrWhiteSpace(current)) + current = "home"; + + var returnUrl = Uri.EscapeDataString("/" + current.TrimStart('/')); + Nav.NavigateTo($"/login?returnUrl={returnUrl}", replace: true); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000..df8c10ff --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/MainLayout.razor.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor new file mode 100644 index 00000000..e740b0c8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ +๏ปฟ + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 00000000..3ad3773f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.js b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 00000000..a44de78d --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AnonymousTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AnonymousTestPage.razor new file mode 100644 index 00000000..10d035ba --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AnonymousTestPage.razor @@ -0,0 +1 @@ +๏ปฟ@page "/anonymous-test" diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor new file mode 100644 index 00000000..5dc5d8aa --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/AuthorizedTestPage.razor @@ -0,0 +1,26 @@ +๏ปฟ@page "/authorized-test" +@attribute [Authorize] + + + + + + + Everything is Ok + + + If you see this section, it means you succesfully logged in. + + + + Go Profile + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Error.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Error.razor new file mode 100644 index 00000000..576cc2d2 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +๏ปฟ@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor new file mode 100644 index 00000000..76de9054 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor @@ -0,0 +1,444 @@ +๏ปฟ@page "/home" +@attribute [Authorize] +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject UAuthClientDiagnostics Diagnostics +@inject AuthenticationStateProvider AuthStateProvider +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Security.Claims +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Custom +@using Microsoft.AspNetCore.Authorization + +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + + + + Set Active + Logout + + + + return; +} + +@if (AuthState?.Identity?.UserStatus == UserStatus.Suspended) +{ + + + + Your account is suspended. Please contact with administrator. + + + + Logout + + + + return; +} + + + + + + + + + + + Session + + + + + + + Validate + + + + + + Manual Refresh + + + + + + Logout + + + + + + Account + + + + + Manage Sessions + + + + Manage Profile + + + + Manage Identifiers + + + + Manage Credentials + + + + Suspend | Delete Account + + + + Admin + + + + + + + + + @if (_showAdminPreview) + { + + Admin operations are shown for preview. Sign in as an Admin to execute them. + + } + + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + { + + + + @* *@ + @* *@ + User Management + @* *@ + + + + + + @* *@ + Role Management + @* *@ + + + + } + + + + + + + + + + + @((AuthState?.Identity?.DisplayName ?? "?").Substring(0, Math.Min(2, (AuthState?.Identity?.DisplayName ?? "?").Length))) + + + + @AuthState?.Identity?.DisplayName + + @foreach (var role in AuthState?.Claims?.Roles ?? Enumerable.Empty()) + { + + @role + + } + + + + + + + + + + @if (_selectedAuthState == "UAuthState") + { + + +
+ + + Tenant + + @AuthState?.Identity?.Tenant.Value +
+ +
+ + +
+ + + User Id + + @AuthState?.Identity?.UserKey.Value +
+
+ + +
+ + + Authenticated + + @(AuthState?.IsAuthenticated == true ? "Yes" : "No") +
+
+ + +
+ + + Session State + + @AuthState?.Identity?.SessionState?.ToDescriptionString() +
+
+ + +
+ + + Username + + @AuthState?.Identity?.PrimaryUserName +
+
+ + +
+ + + Display Name + + @AuthState?.Identity?.DisplayName +
+
+ + + + + + + Email + + @AuthState?.Identity?.PrimaryEmail + + + + + + Phone + + @AuthState?.Identity?.PrimaryPhone + + + + + + + + Authenticated At + + @* TODO: Add IUAuthDateTimeFormatter *@ + @FormatLocalTime(AuthState?.Identity?.AuthenticatedAt) + + + + + + Last Validated At + + @* TODO: Validation call should update last validated at *@ + @FormatLocalTime(AuthState?.LastValidatedAt) + +
+ } + else if (_selectedAuthState == "AspNetCoreState") + { + + +
+ + + Authenticated + + @(_aspNetCoreState?.Identity?.IsAuthenticated == true ? "Yes" : "No") +
+
+ + +
+ + + User Id + + @_aspNetCoreState?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value +
+
+ + +
+ + + Username + + @_aspNetCoreState?.Identity?.Name +
+
+ + +
+ + + Authentication Type + + @_aspNetCoreState?.Identity?.AuthenticationType +
+
+
+ } +
+
+
+ + + + + + @GetHealthText() + + + Lifecycle + + + + + + Started + @Diagnostics.StartCount + + @if (Diagnostics.StartedAt is not null) + { + + + + @FormatRelative(Diagnostics.StartedAt) + + + } + + + + + Stopped + @Diagnostics.StopCount + + + + + + Terminated + @Diagnostics.TerminatedCount + + @if (Diagnostics.TerminatedAt is not null) + { + + + + + @FormatRelative(Diagnostics.TerminatedAt) + + + + } + + + + + + Refresh Metrics + + + + + + + Total Attempts + @Diagnostics.RefreshAttemptCount + + + + + + + Success + + @Diagnostics.RefreshSuccessCount + + + + + + Automatic + @Diagnostics.AutomaticRefreshCount + + + + + + Manual + @Diagnostics.ManualRefreshCount + + + + + + Touched/Rotated + @Diagnostics.RefreshTouchedCount / @Diagnostics.RefreshRotatedCount + + + + + + No-Op + @Diagnostics.RefreshNoOpCount + + + + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + + + + + + +
+
+
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs new file mode 100644 index 00000000..ab0018b8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Home.razor.cs @@ -0,0 +1,222 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Common; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; + +public partial class Home : UAuthFlowPageBase +{ + private string _selectedAuthState = "UAuthState"; + private ClaimsPrincipal? _aspNetCoreState; + + private bool _showAdminPreview = false; + + protected override async Task OnInitializedAsync() + { + var initial = await AuthStateProvider.GetAuthenticationStateAsync(); + _aspNetCoreState = initial.User; + AuthStateProvider.AuthenticationStateChanged += OnAuthStateChanged; + Diagnostics.Changed += OnDiagnosticsChanged; + } + + private void OnAuthStateChanged(Task task) + { + _ = HandleAuthStateChangedAsync(task); + } + + private async Task HandleAuthStateChangedAsync(Task task) + { + try + { + var state = await task; + _aspNetCoreState = state.User; + await InvokeAsync(StateHasChanged); + } + catch + { + + } + } + + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task Logout() => await UAuthClient.Flows.LogoutAsync(); + + private async Task RefreshSession() => await UAuthClient.Flows.RefreshAsync(false); + + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active โ€ข Tenant: {result.Snapshot?.Identity?.Tenant.Value} โ€ข User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + + private Color GetHealthColor() + { + if (Diagnostics.RefreshReauthRequiredCount > 0) + return Color.Warning; + + if (Diagnostics.TerminatedCount > 0) + return Color.Error; + + return Color.Success; + } + + private string GetHealthText() + { + if (Diagnostics.RefreshReauthRequiredCount > 0) + return "Reauthentication Required"; + + if (Diagnostics.TerminatedCount > 0) + return "Session Terminated"; + + return "Healthy"; + } + + private string? FormatRelative(DateTimeOffset? utc) + { + if (utc is null) + return null; + + var diff = DateTimeOffset.UtcNow - utc.Value; + + if (diff.TotalSeconds < 5) + return "just now"; + + if (diff.TotalSeconds < 60) + return $"{(int)diff.Seconds} secs ago"; + + if (diff.TotalMinutes < 60) + return $"{(int)diff.TotalMinutes} min ago"; + + if (diff.TotalHours < 24) + return $"{(int)diff.TotalHours} hrs ago"; + + return utc.Value.ToLocalTime().ToString("dd MMM yyyy"); + } + + private string? FormatLocalTime(DateTimeOffset? utc) + { + return utc?.ToLocalTime().ToString("dd MMM yyyy โ€ข HH:mm:ss"); + } + + private async Task OpenProfileDialog() + { + await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenIdentifierDialog() + { + await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenSessionDialog() + { + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenCredentialDialog() + { + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenAccountStatusDialog() + { + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.ExtraSmall)); + } + + private async Task OpenUserDialog() + { + await DialogService.ShowAsync("User Management", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.Large)); + } + + private async Task OpenRoleDialog() + { + await DialogService.ShowAsync("Role Management", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + + private async Task SetAccountActiveAsync() + { + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.Active }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Account activated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Activation failed.", Severity.Error); + } + } + + public override void Dispose() + { + base.Dispose(); + AuthStateProvider.AuthenticationStateChanged -= OnAuthStateChanged; + Diagnostics.Changed -= OnDiagnosticsChanged; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor new file mode 100644 index 00000000..1e4a9016 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor @@ -0,0 +1,4 @@ +๏ปฟ@page "/" + +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthProvider diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor.cs new file mode 100644 index 00000000..5733e2eb --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/LandingPage.razor.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; + +public partial class LandingPage +{ + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var state = await AuthProvider.GetAuthenticationStateAsync(); + var isAuthenticated = state.User.Identity?.IsAuthenticated == true; + + Nav.NavigateTo(isAuthenticated ? "/home" : $"{UAuthConstants.Routes.LoginRedirect}?fresh=true"); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor new file mode 100644 index 00000000..f1d587c7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor @@ -0,0 +1,126 @@ +๏ปฟ@page "/login" +@attribute [UAuthLoginPage] +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..0559a29b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Login.razor.cs @@ -0,0 +1,211 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Dialogs; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; + +public partial class Login : UAuthFlowPageBase +{ + private string? _username; + private string? _password; + private UAuthClientProductInfo? _productInfo; + private MudTextField _usernameField = default!; + + private CancellationTokenSource? _lockoutCts; + private PeriodicTimer? _lockoutTimer; + private DateTimeOffset? _lockoutUntil; + private TimeSpan _remaining; + private bool _isLocked; + private DateTimeOffset? _lockoutStartedAt; + private TimeSpan _lockoutDuration; + private double _progressPercent; + private int? _remainingAttempts = null; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + protected override Task OnUAuthPayloadAsync(AuthFlowPayload payload) + { + HandleLoginPayload(payload); + return Task.CompletedTask; + } + + protected override async Task OnFocusRequestedAsync() + { + await _usernameField.FocusAsync(); + } + + private void HandleLoginPayload(AuthFlowPayload payload) + { + if (payload.Flow != AuthFlowType.Login) + return; + + if (payload.Reason == AuthFailureReason.LockedOut && payload.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = payload.RemainingAttempts; + ShowLoginError(payload.Reason, payload.RemainingAttempts); + } + + private void ShowLoginError(AuthFailureReason? reason, int? remainingAttempts) + { + string message = reason switch + { + AuthFailureReason.InvalidCredentials when remainingAttempts is > 0 + => $"Invalid username or password. {remainingAttempts} attempt(s) remaining.", + + AuthFailureReason.InvalidCredentials + => "Invalid username or password.", + + AuthFailureReason.RequiresMfa + => "Multi-factor authentication required.", + + AuthFailureReason.LockedOut + => "Your account is locked.", + + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private async Task ProgrammaticLogin() + { + var request = new LoginRequest + { + Identifier = "admin", + Secret = "admin", + }; + await UAuthClient.Flows.LoginAsync(request, ReturnUrl ?? "/home"); + } + + private async void StartCountdown() + { + if (_lockoutUntil is null) + return; + + _isLocked = true; + _lockoutStartedAt = DateTimeOffset.UtcNow; + _lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow; + UpdateRemaining(); + + _lockoutCts?.Cancel(); + _lockoutCts = new CancellationTokenSource(); + + _lockoutTimer?.Dispose(); + _lockoutTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + while (await _lockoutTimer.WaitForNextTickAsync(_lockoutCts.Token)) + { + UpdateRemaining(); + + if (_remaining <= TimeSpan.Zero) + { + ResetLockoutState(); + await InvokeAsync(StateHasChanged); + break; + } + + await InvokeAsync(StateHasChanged); + } + } + catch (OperationCanceledException) + { + + } + } + + private void ResetLockoutState() + { + _isLocked = false; + _lockoutUntil = null; + _progressPercent = 0; + _remainingAttempts = null; + } + + private void UpdateRemaining() + { + if (_lockoutUntil is null || _lockoutStartedAt is null) + return; + + var now = DateTimeOffset.UtcNow; + + _remaining = _lockoutUntil.Value - now; + + if (_remaining <= TimeSpan.Zero) + { + _remaining = TimeSpan.Zero; + return; + } + + var elapsed = now - _lockoutStartedAt.Value; + + if (_lockoutDuration.TotalSeconds > 0) + { + var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100); + _progressPercent = Math.Max(0, percent); + } + } + + private void HandleTry(IUAuthTryResult result) + { + if (result is TryLoginResult pkce) + { + if (!result.Success) + { + if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = result.RemainingAttempts; + ShowLoginError(result.Reason, result.RemainingAttempts); + } + } + else + { + Snackbar.Add("Unexpected result type.", Severity.Error); + } + } + + private async Task OpenResetDialog() + { + await DialogService.ShowAsync("Reset Credentials", GetDialogParameters(), GetDialogOptions()); + } + + private DialogOptions GetDialogOptions() + { + return new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + + public override void Dispose() + { + base.Dispose(); + _lockoutCts?.Cancel(); + _lockoutTimer?.Dispose(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor new file mode 100644 index 00000000..d8eb7138 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor @@ -0,0 +1,27 @@ +๏ปฟ@inject NavigationManager Nav + + + + + + + Access Denied + + + You donโ€™t have permission to view this page. + If you think this is a mistake, sign in with a different account or request access. + + + + Sign In + Go Back + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor.cs new file mode 100644 index 00000000..b38fbe97 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/NotAuthorized.razor.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; + +public partial class NotAuthorized +{ + private string LoginHref + { + get + { + var returnUrl = Uri.EscapeDataString(Nav.ToBaseRelativePath(Nav.Uri)); + return $"/login?returnUrl=/{returnUrl}"; + } + } + + private void GoBack() => Nav.NavigateTo("/", replace: false); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor new file mode 100644 index 00000000..881cae5c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor @@ -0,0 +1,60 @@ +๏ปฟ@page "/register" +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor.cs new file mode 100644 index 00000000..e8c16205 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/Register.razor.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Users.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; + +public partial class Register +{ + private string? _username; + private string? _password; + private string? _passwordCheck; + private string? _email; + private UAuthClientProductInfo? _productInfo; + private MudForm _form = null!; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + private async Task HandleRegisterAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + var request = new CreateUserRequest + { + UserName = _username, + Password = _password, + Email = _email, + }; + + var result = await UAuthClient.Users.CreateAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("User created successfully.", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to create user.", Severity.Error); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor new file mode 100644 index 00000000..753878b8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor @@ -0,0 +1,18 @@ +๏ปฟ@page "/reset" +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + + + + + + Change Password + + + + \ No newline at end of file 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 new file mode 100644 index 00000000..21fad18a --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Pages/ResetCredential.razor.cs @@ -0,0 +1,49 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages; + +public partial class ResetCredential +{ + private MudForm _form = null!; + private string? _code; + private string? _newPassword; + private string? _newPasswordCheck; + + private async Task ResetPasswordAsync() + { + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("Passwords do not match.", Severity.Error); + return; + } + + var request = new CompleteResetCredentialRequest + { + ResetToken = _code, + NewSecret = _newPassword ?? string.Empty, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + }; + + var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to reset credential. Please try again.", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Routes.razor new file mode 100644 index 00000000..60331591 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/Routes.razor @@ -0,0 +1,73 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Pages +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Infrastructure +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + @* Advanced: you can fully control routing by providing your own Router (in the commented code below) *@ + @* + + + + + + + + + + + + + + + + *@ + + +@code { + private async Task HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } + + #region DarkMode + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + private void OnThemeChanged() + { + InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await DarkModeManager.InitializeAsync(); + StateHasChanged(); + } + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } + + #endregion +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/_Imports.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/_Imports.razor new file mode 100644 index 00000000..59225b8c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Components/_Imports.razor @@ -0,0 +1,23 @@ +๏ปฟ@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Authorization +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components +@using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components.Layout + +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Blazor + +@using MudBlazor +@using MudExtensions diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Infrastructure/DarkModeManager.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Infrastructure/DarkModeManager.cs new file mode 100644 index 00000000..befbcb29 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Infrastructure/DarkModeManager.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Infrastructure; + +public sealed class DarkModeManager +{ + private const string StorageKey = "uauth:theme:dark"; + + private readonly IClientStorage _storage; + + public DarkModeManager(IClientStorage storage) + { + _storage = storage; + } + + public async Task InitializeAsync() + { + var value = await _storage.GetAsync(StorageScope.Local, StorageKey); + + if (bool.TryParse(value, out var parsed)) + IsDarkMode = parsed; + } + + public bool IsDarkMode { get; set; } + + public event Action? Changed; + + public async Task ToggleAsync() + { + IsDarkMode = !IsDarkMode; + + await _storage.SetAsync(StorageScope.Local, StorageKey, IsDarkMode.ToString()); + Changed?.Invoke(); + } + + public void Set(bool value) + { + if (IsDarkMode == value) + return; + + IsDarkMode = value; + Changed?.Invoke(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs new file mode 100644 index 00000000..079580bb --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.Designer.cs @@ -0,0 +1,710 @@ +๏ปฟ// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + [Migration("20260327184128_InitUltimateAuth")] + partial class InitUltimateAuth + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialType") + .HasColumnType("INTEGER"); + + b.Property("FailedAttempts") + .HasColumnType("INTEGER"); + + b.Property("LastFailedAt") + .HasColumnType("TEXT"); + + b.Property("LockedUntil") + .HasColumnType("TEXT"); + + b.Property("RequiresReauthentication") + .HasColumnType("INTEGER"); + + b.Property("ResetAttempts") + .HasColumnType("INTEGER"); + + b.Property("ResetConsumedAt") + .HasColumnType("TEXT"); + + b.Property("ResetExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ResetRequestedAt") + .HasColumnType("TEXT"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "LockedUntil"); + + b.HasIndex("Tenant", "ResetRequestedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "Scope"); + + b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") + .IsUnique(); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "RoleId", "Permission"); + + b.HasIndex("Tenant", "Permission"); + + b.HasIndex("Tenant", "RoleId"); + + b.ToTable("UAuth_RolePermissions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RoleProjection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "NormalizedName") + .IsUnique(); + + b.ToTable("UAuth_Roles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.UserRoleProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "UserKey", "RoleId"); + + b.HasIndex("Tenant", "RoleId"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserRoles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordCredentialProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeletedAt"); + + b.ToTable("UAuth_PasswordCredentials", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ActiveSessionId") + .HasColumnType("TEXT"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasColumnType("TEXT"); + + b.Property("RotationCount") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TouchCount") + .HasColumnType("INTEGER"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId") + .IsUnique(); + + b.HasIndex("Tenant", "RootId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeviceId"); + + b.ToTable("UAuth_SessionChains", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "SessionId") + .IsUnique(); + + b.HasIndex("Tenant", "ChainId", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_Sessions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenId") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "ReplacedByTokenHash"); + + b.HasIndex("Tenant", "SessionId"); + + b.HasIndex("Tenant", "TokenHash") + .IsUnique(); + + b.HasIndex("Tenant", "TokenId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "ExpiresAt", "RevokedAt"); + + b.HasIndex("Tenant", "TokenHash", "RevokedAt"); + + b.ToTable("UAuth_RefreshTokens", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserIdentifierProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("VerifiedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "NormalizedValue"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "Type", "NormalizedValue") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey", "IsPrimary"); + + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserProfiles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", null) + .WithMany() + .HasForeignKey("Tenant", "RootId") + .HasPrincipalKey("Tenant", "RootId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", null) + .WithMany() + .HasForeignKey("Tenant", "ChainId") + .HasPrincipalKey("Tenant", "ChainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs new file mode 100644 index 00000000..9f373138 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/20260327184128_InitUltimateAuth.cs @@ -0,0 +1,556 @@ +๏ปฟusing System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + /// + public partial class InitUltimateAuth : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UAuth_Authentication", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Scope = table.Column(type: "INTEGER", nullable: false), + CredentialType = table.Column(type: "INTEGER", nullable: true), + FailedAttempts = table.Column(type: "INTEGER", nullable: false), + LastFailedAt = table.Column(type: "TEXT", nullable: true), + LockedUntil = table.Column(type: "TEXT", nullable: true), + RequiresReauthentication = table.Column(type: "INTEGER", nullable: false), + ResetRequestedAt = table.Column(type: "TEXT", nullable: true), + ResetExpiresAt = table.Column(type: "TEXT", nullable: true), + ResetConsumedAt = table.Column(type: "TEXT", nullable: true), + ResetTokenHash = table.Column(type: "TEXT", maxLength: 512, nullable: true), + ResetAttempts = table.Column(type: "INTEGER", nullable: false), + SecurityVersion = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_Authentication", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_PasswordCredentials", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + SecretHash = table.Column(type: "TEXT", maxLength: 512, nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true), + ExpiresAt = table.Column(type: "TEXT", nullable: true), + SecurityStamp = table.Column(type: "TEXT", nullable: false), + LastUsedAt = table.Column(type: "TEXT", nullable: true), + Source = table.Column(type: "TEXT", maxLength: 128, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_PasswordCredentials", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_RefreshTokens", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TokenId = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + TokenHash = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + SessionId = table.Column(type: "TEXT", nullable: false), + ChainId = table.Column(type: "TEXT", nullable: true), + ReplacedByTokenHash = table.Column(type: "TEXT", maxLength: 128, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_RefreshTokens", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_RolePermissions", + columns: table => new + { + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false), + Permission = table.Column(type: "TEXT", maxLength: 256, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_RolePermissions", x => new { x.Tenant, x.RoleId, x.Permission }); + }); + + migrationBuilder.CreateTable( + name: "UAuth_Roles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 128, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_SessionRoots", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + RootId = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + RevokedAt = table.Column(type: "TEXT", nullable: true), + SecurityVersion = table.Column(type: "INTEGER", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_SessionRoots", x => x.Id); + table.UniqueConstraint("AK_UAuth_SessionRoots_Tenant_RootId", x => new { x.Tenant, x.RootId }); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserIdentifiers", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Type = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", maxLength: 256, nullable: false), + NormalizedValue = table.Column(type: "TEXT", maxLength: 256, nullable: false), + IsPrimary = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + VerifiedAt = table.Column(type: "TEXT", nullable: true), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserIdentifiers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserLifecycles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + SecurityVersion = table.Column(type: "INTEGER", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserLifecycles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserProfiles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + FirstName = table.Column(type: "TEXT", nullable: true), + LastName = table.Column(type: "TEXT", nullable: true), + DisplayName = table.Column(type: "TEXT", nullable: true), + BirthDate = table.Column(type: "TEXT", nullable: true), + Gender = table.Column(type: "TEXT", nullable: true), + Bio = table.Column(type: "TEXT", nullable: true), + Language = table.Column(type: "TEXT", nullable: true), + TimeZone = table.Column(type: "TEXT", nullable: true), + Culture = table.Column(type: "TEXT", nullable: true), + Metadata = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false), + UpdatedAt = table.Column(type: "TEXT", nullable: true), + DeletedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserProfiles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "UAuth_UserRoles", + columns: table => new + { + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + RoleId = table.Column(type: "TEXT", nullable: false), + AssignedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_UserRoles", x => new { x.Tenant, x.UserKey, x.RoleId }); + }); + + migrationBuilder.CreateTable( + name: "UAuth_SessionChains", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + ChainId = table.Column(type: "TEXT", nullable: false), + RootId = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + LastSeenAt = table.Column(type: "TEXT", nullable: false), + AbsoluteExpiresAt = table.Column(type: "TEXT", nullable: true), + DeviceId = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Device = table.Column(type: "TEXT", nullable: false), + ClaimsSnapshot = table.Column(type: "TEXT", nullable: false), + ActiveSessionId = table.Column(type: "TEXT", nullable: true), + RotationCount = table.Column(type: "INTEGER", nullable: false), + TouchCount = table.Column(type: "INTEGER", nullable: false), + SecurityVersionAtCreation = table.Column(type: "INTEGER", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_SessionChains", x => x.Id); + table.UniqueConstraint("AK_UAuth_SessionChains_Tenant_ChainId", x => new { x.Tenant, x.ChainId }); + table.ForeignKey( + name: "FK_UAuth_SessionChains_UAuth_SessionRoots_Tenant_RootId", + columns: x => new { x.Tenant, x.RootId }, + principalTable: "UAuth_SessionRoots", + principalColumns: new[] { "Tenant", "RootId" }, + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "UAuth_Sessions", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + SessionId = table.Column(type: "TEXT", nullable: false), + ChainId = table.Column(type: "TEXT", nullable: false), + Tenant = table.Column(type: "TEXT", maxLength: 128, nullable: false), + UserKey = table.Column(type: "TEXT", maxLength: 128, nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + ExpiresAt = table.Column(type: "TEXT", nullable: false), + RevokedAt = table.Column(type: "TEXT", nullable: true), + SecurityVersionAtCreation = table.Column(type: "INTEGER", nullable: false), + Device = table.Column(type: "TEXT", nullable: false), + Claims = table.Column(type: "TEXT", nullable: false), + Metadata = table.Column(type: "TEXT", nullable: false), + Version = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UAuth_Sessions", x => x.Id); + table.ForeignKey( + name: "FK_UAuth_Sessions_UAuth_SessionChains_Tenant_ChainId", + columns: x => new { x.Tenant, x.ChainId }, + principalTable: "UAuth_SessionChains", + principalColumns: new[] { "Tenant", "ChainId" }, + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_LockedUntil", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "LockedUntil" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_ResetRequestedAt", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "ResetRequestedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey", "Scope" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Authentication_Tenant_UserKey_Scope_CredentialType", + table: "UAuth_Authentication", + columns: new[] { "Tenant", "UserKey", "Scope", "CredentialType" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_ExpiresAt", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_Id", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "Id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_RevokedAt", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_UserKey", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_PasswordCredentials_Tenant_UserKey_DeletedAt", + table: "UAuth_PasswordCredentials", + columns: new[] { "Tenant", "UserKey", "DeletedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ChainId", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ChainId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ExpiresAt", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ExpiresAt_RevokedAt", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ExpiresAt", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_ReplacedByTokenHash", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "ReplacedByTokenHash" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_SessionId", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "SessionId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_TokenHash", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "TokenHash" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_TokenHash_RevokedAt", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "TokenHash", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_TokenId", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "TokenId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RefreshTokens_Tenant_UserKey", + table: "UAuth_RefreshTokens", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RolePermissions_Tenant_Permission", + table: "UAuth_RolePermissions", + columns: new[] { "Tenant", "Permission" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_RolePermissions_Tenant_RoleId", + table: "UAuth_RolePermissions", + columns: new[] { "Tenant", "RoleId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Roles_Tenant_Id", + table: "UAuth_Roles", + columns: new[] { "Tenant", "Id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Roles_Tenant_NormalizedName", + table: "UAuth_Roles", + columns: new[] { "Tenant", "NormalizedName" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_ChainId", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "ChainId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_RootId", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "RootId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_UserKey", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionChains_Tenant_UserKey_DeviceId", + table: "UAuth_SessionChains", + columns: new[] { "Tenant", "UserKey", "DeviceId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_RootId", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "RootId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_SessionRoots_Tenant_UserKey", + table: "UAuth_SessionRoots", + columns: new[] { "Tenant", "UserKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_ChainId", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "ChainId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_ChainId_RevokedAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "ChainId", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_ExpiresAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "ExpiresAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_RevokedAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_SessionId", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "SessionId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_Sessions_Tenant_UserKey_RevokedAt", + table: "UAuth_Sessions", + columns: new[] { "Tenant", "UserKey", "RevokedAt" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_NormalizedValue", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "NormalizedValue" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_Type_NormalizedValue", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "Type", "NormalizedValue" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_IsPrimary", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "UserKey", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserIdentifiers_Tenant_UserKey_Type_IsPrimary", + table: "UAuth_UserIdentifiers", + columns: new[] { "Tenant", "UserKey", "Type", "IsPrimary" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserLifecycles_Tenant_UserKey", + table: "UAuth_UserLifecycles", + columns: new[] { "Tenant", "UserKey" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserProfiles_Tenant_UserKey", + table: "UAuth_UserProfiles", + columns: new[] { "Tenant", "UserKey" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserRoles_Tenant_RoleId", + table: "UAuth_UserRoles", + columns: new[] { "Tenant", "RoleId" }); + + migrationBuilder.CreateIndex( + name: "IX_UAuth_UserRoles_Tenant_UserKey", + table: "UAuth_UserRoles", + columns: new[] { "Tenant", "UserKey" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UAuth_Authentication"); + + migrationBuilder.DropTable( + name: "UAuth_PasswordCredentials"); + + migrationBuilder.DropTable( + name: "UAuth_RefreshTokens"); + + migrationBuilder.DropTable( + name: "UAuth_RolePermissions"); + + migrationBuilder.DropTable( + name: "UAuth_Roles"); + + migrationBuilder.DropTable( + name: "UAuth_Sessions"); + + migrationBuilder.DropTable( + name: "UAuth_UserIdentifiers"); + + migrationBuilder.DropTable( + name: "UAuth_UserLifecycles"); + + migrationBuilder.DropTable( + name: "UAuth_UserProfiles"); + + migrationBuilder.DropTable( + name: "UAuth_UserRoles"); + + migrationBuilder.DropTable( + name: "UAuth_SessionChains"); + + migrationBuilder.DropTable( + name: "UAuth_SessionRoots"); + } + } +} 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 new file mode 100644 index 00000000..211ef12e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Migrations/UAuthDbContextModelSnapshot.cs @@ -0,0 +1,707 @@ +๏ปฟ// +using System; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Migrations +{ + [DbContext(typeof(UAuthDbContext))] + partial class UAuthDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.AuthenticationSecurityStateProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CredentialType") + .HasColumnType("INTEGER"); + + b.Property("FailedAttempts") + .HasColumnType("INTEGER"); + + b.Property("LastFailedAt") + .HasColumnType("TEXT"); + + b.Property("LockedUntil") + .HasColumnType("TEXT"); + + b.Property("RequiresReauthentication") + .HasColumnType("INTEGER"); + + b.Property("ResetAttempts") + .HasColumnType("INTEGER"); + + b.Property("ResetConsumedAt") + .HasColumnType("TEXT"); + + b.Property("ResetExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ResetRequestedAt") + .HasColumnType("TEXT"); + + b.Property("ResetTokenHash") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Scope") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "LockedUntil"); + + b.HasIndex("Tenant", "ResetRequestedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "Scope"); + + b.HasIndex("Tenant", "UserKey", "Scope", "CredentialType") + .IsUnique(); + + b.ToTable("UAuth_Authentication", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RolePermissionProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("Permission") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "RoleId", "Permission"); + + b.HasIndex("Tenant", "Permission"); + + b.HasIndex("Tenant", "RoleId"); + + b.ToTable("UAuth_RolePermissions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.RoleProjection", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "NormalizedName") + .IsUnique(); + + b.ToTable("UAuth_Roles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.UserRoleProjection", b => + { + b.Property("Tenant") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.Property("AssignedAt") + .HasColumnType("TEXT"); + + b.HasKey("Tenant", "UserKey", "RoleId"); + + b.HasIndex("Tenant", "RoleId"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserRoles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.PasswordCredentialProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("LastUsedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecretHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "Id") + .IsUnique(); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeletedAt"); + + b.ToTable("UAuth_PasswordCredentials", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AbsoluteExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ActiveSessionId") + .HasColumnType("TEXT"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("ClaimsSnapshot") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasColumnType("TEXT"); + + b.Property("RotationCount") + .HasColumnType("INTEGER"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TouchCount") + .HasColumnType("INTEGER"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId") + .IsUnique(); + + b.HasIndex("Tenant", "RootId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "UserKey", "DeviceId"); + + b.ToTable("UAuth_SessionChains", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("Claims") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Device") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersionAtCreation") + .HasColumnType("INTEGER"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "RevokedAt"); + + b.HasIndex("Tenant", "SessionId") + .IsUnique(); + + b.HasIndex("Tenant", "ChainId", "RevokedAt"); + + b.HasIndex("Tenant", "UserKey", "RevokedAt"); + + b.ToTable("UAuth_Sessions", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("RootId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "RootId") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_SessionRoots", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.RefreshTokenProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChainId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ReplacedByTokenHash") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("RevokedAt") + .HasColumnType("TEXT"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TokenId") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "ChainId"); + + b.HasIndex("Tenant", "ExpiresAt"); + + b.HasIndex("Tenant", "ReplacedByTokenHash"); + + b.HasIndex("Tenant", "SessionId"); + + b.HasIndex("Tenant", "TokenHash") + .IsUnique(); + + b.HasIndex("Tenant", "TokenId"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "ExpiresAt", "RevokedAt"); + + b.HasIndex("Tenant", "TokenHash", "RevokedAt"); + + b.ToTable("UAuth_RefreshTokens", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserIdentifierProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("IsPrimary") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("VerifiedAt") + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "NormalizedValue"); + + b.HasIndex("Tenant", "UserKey"); + + b.HasIndex("Tenant", "Type", "NormalizedValue") + .IsUnique(); + + b.HasIndex("Tenant", "UserKey", "IsPrimary"); + + b.HasIndex("Tenant", "UserKey", "Type", "IsPrimary"); + + b.ToTable("UAuth_UserIdentifiers", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserLifecycleProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("SecurityVersion") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey") + .IsUnique(); + + b.ToTable("UAuth_UserLifecycles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Users.EntityFrameworkCore.UserProfileProjection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasColumnType("TEXT"); + + b.Property("BirthDate") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Culture") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("Gender") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("Metadata") + .HasColumnType("TEXT"); + + b.Property("Tenant") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.Property("UserKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Version") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Tenant", "UserKey"); + + b.ToTable("UAuth_UserProfiles", (string)null); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionRootProjection", null) + .WithMany() + .HasForeignKey("Tenant", "RootId") + .HasPrincipalKey("Tenant", "RootId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionProjection", b => + { + b.HasOne("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.SessionChainProjection", null) + .WithMany() + .HasForeignKey("Tenant", "ChainId") + .HasPrincipalKey("Tenant", "ChainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs new file mode 100644 index 00000000..377eb8b1 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Program.cs @@ -0,0 +1,111 @@ +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Components; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore.Infrastructure; +using CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore; +using CodeBeam.UltimateAuth.Sample.Seed.Extensions; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.EntityFrameworkCore; +using MudBlazor.Services; +using MudExtensions.Services; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +#region Core + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); + +builder.Services.AddOpenApi(); + +#endregion + +# region UI & MudBlazor & Extensions + +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); +builder.Services.AddMudExtensions(); +builder.Services.AddScoped(); + +#endregion + + +builder.Services.AddUltimateAuthServer(o => +{ + o.Diagnostics.EnableRefreshDetails = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); + //o.Session.Lifetime = TimeSpan.FromSeconds(32); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); + //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); + //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.AllowMultipleUsernames = true; +}) + .AddUltimateAuthEntityFrameworkCore(db => + { + db.UseSqlite("Data Source=uauth.db", x => x.MigrationsAssembly("CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore")); + }); + +builder.Services.AddUltimateAuthClientBlazor(o => +{ + //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; +}); + +builder.Services.AddScopedUltimateAuthSampleSeed(); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; +}); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} +else +{ + app.MapOpenApi(); + app.MapScalarApiReference(); + + using (var scope = app.Services.CreateScope()) + { + await UAuthDbInitializer.InitializeAsync(app.Services, reset: true); + + var seedRunner = scope.ServiceProvider.GetRequiredService(); + await seedRunner.RunAsync(null); + } +} + +app.UseForwardedHeaders(); + +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseUltimateAuthWithAspNetCore(); +app.UseAntiforgery(); + +app.MapUltimateAuthEndpoints(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); + +app.Run(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Properties/launchSettings.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Properties/launchSettings.json new file mode 100644 index 00000000..45dcb3a9 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5276", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7230;http://localhost:5276", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Seed/UAuthDbInitializer.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Seed/UAuthDbInitializer.cs new file mode 100644 index 00000000..4b4e23c4 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/Seed/UAuthDbInitializer.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore; + +public static class UAuthDbInitializer +{ + public static async Task InitializeAsync(IServiceProvider services, bool reset = false) + { + using var scope = services.CreateScope(); + var sp = scope.ServiceProvider; + + var bundleDb = sp.GetService(); + + if (bundleDb != null) + { + if (reset) + await bundleDb.Database.EnsureDeletedAsync(); + + await bundleDb.Database.MigrateAsync(); + return; + } + + var contexts = new DbContext[] + { + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + }; + + if (reset) + await contexts[0].Database.EnsureDeletedAsync(); + + foreach (var db in contexts) + await db.Database.MigrateAsync(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.Development.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db new file mode 100644 index 00000000..4e86411b Binary files /dev/null and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db differ 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 new file mode 100644 index 00000000..9c25688e Binary files /dev/null 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 new file mode 100644 index 00000000..42498f65 Binary files /dev/null and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/uauth.db-wal differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/UltimateAuth-Logo.png b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/UltimateAuth-Logo.png new file mode 100644 index 00000000..5b7282f1 Binary files /dev/null and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/UltimateAuth-Logo.png differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/app.css new file mode 100644 index 00000000..2b9a4745 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer.EFCore/wwwroot/app.css @@ -0,0 +1,143 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.uauth-stack { + min-height: 60vh; + max-height: calc(100vh - var(--mud-appbar-height)); + width: 30vw; + min-width: 300px; +} + +.uauth-menu-popover { + width: 300px; +} + +.uauth-login-paper { + min-height: 70vh; +} + +.uauth-login-paper.mud-theme-primary { + background: linear-gradient(145deg, var(--mud-palette-primary), rgba(0, 0, 0, 0.85) ); + color: white; +} + +.uauth-brand-glow { + filter: drop-shadow(0 0 25px rgba(255,255,255,0.15)); +} + +.uauth-logo-slide { + animation: uauth-logo-float 30s ease-in-out infinite; +} + +.uauth-text-transform-none .mud-button { + text-transform: none; +} + +.uauth-dialog { + height: 68vh; + max-height: 68vh; + overflow: auto; +} + +.text-secondary { + color: var(--mud-palette-text-secondary); +} + +.uauth-blur { + backdrop-filter: blur(10px); +} + +.uauth-blur-slight { + backdrop-filter: blur(4px); +} + +@keyframes uauth-logo-float { + 0% { + transform: translateY(0) rotateY(0); + } + + 10% { + transform: translateY(0) rotateY(0); + } + + 15% { + transform: translateY(200px) rotateY(360deg); + } + + 35% { + transform: translateY(200px) rotateY(360deg); + } + + 40% { + transform: translateY(200px) rotateY(720deg); + } + + 60% { + transform: translateY(200px) rotateY(720deg); + } + + 65% { + transform: translateY(0) rotateY(360deg); + } + + 85% { + transform: translateY(0) rotateY(360deg); + } + + 90% { + transform: translateY(0) rotateY(0); + } + + 100% { + transform: translateY(0) rotateY(0); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor new file mode 100644 index 00000000..2806b7d3 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor @@ -0,0 +1,19 @@ +๏ปฟ@namespace CodeBeam.UltimateAuth.Sample +@inherits ComponentBase + + + + @if (Variant == UAuthLogoVariant.Brand) + { + + + + } + else + { + + + + } + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor.cs new file mode 100644 index 00000000..030d9b66 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogo.razor.cs @@ -0,0 +1,54 @@ +๏ปฟusing Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace CodeBeam.UltimateAuth.Sample; + +public partial class UAuthLogo : ComponentBase +{ + [Parameter] public UAuthLogoVariant Variant { get; set; } = UAuthLogoVariant.Brand; + + [Parameter] public int Size { get; set; } = 32; + + [Parameter] public string? ShieldColor { get; set; } = "#00072d"; + [Parameter] public string? KeyColor { get; set; } = "#f6f5ae"; + + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } + + private string BuildStyle() + { + if (Variant == UAuthLogoVariant.Mono) + return $"color: {KeyColor}; {Style}"; + + return Style ?? ""; + } + + protected string KeyPath => @" +M120.43,39.44H79.57A11.67,11.67,0,0,0,67.9,51.11V77.37 +A11.67,11.67,0,0,0,79.57,89H90.51l3.89,3.9v5.32l-3.8,3.81v81.41H99 +v-5.33h13.69V169H108.1v-3.8H99C99,150.76,111.9,153,111.9,153 +V99.79h-8V93.32L108.19,89h12.24 +A11.67,11.67,0,0,0,132.1,77.37V51.11 +A11.67,11.67,0,0,0,120.43,39.44Z + +M79.57,48.19h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.84a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.84a2.91,2.91 0 0 1 2.91,-2.92Z + +M79.57,68.62h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.83a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.83a2.91,2.91 0 0 1 2.91,-2.92Z + +M114.59,48.19h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.84a2.91,2.91 0 0 1 -2.91,2.91 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.91 +v-5.84a2.92,2.92 0 0 1 2.92,-2.92Z + +M114.59,68.62h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.83a2.91,2.91 0 0 1 -2.91,2.92 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.92 +v-5.83a2.92,2.92 0 0 1 2.92,-2.92Z +"; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogoVariant.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogoVariant.cs new file mode 100644 index 00000000..fe3be220 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Brand/UAuthLogoVariant.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample; + +public enum UAuthLogoVariant +{ + Brand, + Mono +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj new file mode 100644 index 00000000..9d71e7d7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/CodeBeam.UltimateAuth.Sample.BlazorServer.csproj @@ -0,0 +1,23 @@ +๏ปฟ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs new file mode 100644 index 00000000..58dae70b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Common/UAuthDialog.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Common; + +public static class UAuthDialog +{ + public static DialogParameters GetDialogParameters(UAuthState state, UserKey? userKey = null) + { + DialogParameters parameters = new DialogParameters(); + parameters.Add("AuthState", state); + if (userKey != null ) + { + parameters.Add("UserKey", userKey); + } + return parameters; + } + + public static DialogOptions GetDialogOptions(MaxWidth maxWidth = MaxWidth.Medium) + { + return new DialogOptions + { + MaxWidth = maxWidth, + FullWidth = true, + CloseButton = true + }; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor new file mode 100644 index 00000000..087e4279 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/App.razor @@ -0,0 +1,25 @@ +๏ปฟ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor new file mode 100644 index 00000000..5af543e4 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Custom/UAuthPageComponent.razor @@ -0,0 +1,10 @@ +๏ปฟ + + @ChildContent + + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor new file mode 100644 index 00000000..0c91e45c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor @@ -0,0 +1,23 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + Suspend Account + + + + Delete Account + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor.cs new file mode 100644 index 00000000..4fda31ea --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/AccountStatusDialog.razor.cs @@ -0,0 +1,77 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class AccountStatusDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task SuspendAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to suspend your account.

+ You can still active your account later. + """, + yesText: "Suspend", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Suspend process cancelled.", Severity.Info); + return; + } + + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + } + } + + private async Task DeleteAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to delete your account.

+ This action can't be undone.

+ (Actually it is, admin can handle soft deleted accounts.) + """, + yesText: "Delete", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Deletion cancelled.", Severity.Info); + return; + } + + var result = await UAuthClient.Users.DeleteMeAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Your account deleted successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor new file mode 100644 index 00000000..9a514935 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor @@ -0,0 +1,27 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Create User + + + + + + + + + + + + + + + + Cancel + Create + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor.cs new file mode 100644 index 00000000..ec16e78f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CreateUserDialog.razor.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class CreateUserDialog +{ + private MudForm _form = null!; + private string? _username; + private string? _email; + private string? _password; + private string? _passwordCheck; + private string? _displayName; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + private async Task CreateUserAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + if (_password != _passwordCheck) + { + Snackbar.Add("Passwords don't match.", Severity.Error); + return; + } + + var request = new CreateUserRequest + { + UserName = _username, + Email = _email, + DisplayName = _displayName, + Password = _password + }; + + var result = await UAuthClient.Users.CreateAsAdminAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "User creation failed.", Severity.Error); + return; + } + + Snackbar.Add("User created successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + + private string PasswordMatch(string? arg) => _password != arg ? "Passwords don't match." : string.Empty; + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor new file mode 100644 index 00000000..660b7c3a --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor @@ -0,0 +1,51 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Credential Management + User: @AuthState?.Identity?.DisplayName + + + + + @if (UserKey == null) + { + + + + } + else + { + + + Administrators can directly assign passwords to users. + However, using the credential reset flow is generally recommended for better security and auditability. + + + } + + + + + + + + + + + @(UserKey is null ? "Change Password" : "Set Password") + + + + + + Cancel + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs new file mode 100644 index 00000000..ee48c215 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/CredentialDialog.razor.cs @@ -0,0 +1,92 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class CredentialDialog +{ + private MudForm _form = null!; + private string? _oldPassword; + private string? _newPassword; + private string? _newPasswordCheck; + private bool _passwordMode1 = false; + private bool _passwordMode2 = false; + private bool _passwordMode3 = true; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + private async Task ChangePasswordAsync() + { + if (_form is null) + return; + + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Form is not valid.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("New password and check do not match", Severity.Error); + return; + } + + ChangeCredentialRequest request; + + if (UserKey is null) + { + request = new ChangeCredentialRequest + { + CurrentSecret = _oldPassword!, + NewSecret = _newPassword! + }; + } + else + { + request = new ChangeCredentialRequest + { + NewSecret = _newPassword! + }; + } + + UAuthResult result; + if (UserKey is null) + { + result = await UAuthClient.Credentials.ChangeMyAsync(request); + } + else + { + result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Password changed successfully", Severity.Success); + _oldPassword = null; + _newPassword = null; + _newPasswordCheck = null; + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.ErrorText ?? "An error occurred while changing password", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor new file mode 100644 index 00000000..0d631533 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor @@ -0,0 +1,106 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + + + + Identifiers + + + + + + + + + + + + + + + + + + + + + + + + + @if (context.Item.IsPrimary) + { + + + + } + else + { + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add + + + + + + + + Cancel + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs new file mode 100644 index 00000000..1a2f0a76 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/IdentifierDialog.razor.cs @@ -0,0 +1,309 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class IdentifierDialog +{ + private MudDataGrid? _grid; + private UserIdentifierType _newIdentifierType; + private string? _newIdentifierValue; + private bool _newIdentifierPrimary; + private bool _loading = false; + private bool _reloadQueued; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Identifiers.GetMyAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + StateHasChanged(); + } + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + UAuthResult> res; + + if (UserKey is null) + { + res = await UAuthClient.Identifiers.GetMyAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserAsync(UserKey.Value, req); + } + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task CommittedItemChanges(UserIdentifierInfo item) + { + UpdateUserIdentifierRequest updateRequest = new() + { + Id = item.Id, + NewValue = item.Value + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier updated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to update identifier", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task AddNewIdentifier() + { + if (string.IsNullOrEmpty(_newIdentifierValue)) + { + Snackbar.Add("Value cannot be empty", Severity.Warning); + return; + } + + AddUserIdentifierRequest request = new() + { + Type = _newIdentifierType, + Value = _newIdentifierValue, + IsPrimary = _newIdentifierPrimary + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.AddMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier added successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier.", Severity.Error); + } + } + + private async Task VerifyAsync(Guid id) + { + var demoInfo = await DialogService.ShowMessageBoxAsync( + title: "Demo Verification", + markupMessage: (MarkupString) + """ + This is a demo action.

+ In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. + This will only mark the identifier as verified in UltimateAuth. + """, + yesText: "Verify", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (demoInfo != true) + { + Snackbar.Add("Verification cancelled", Severity.Info); + return; + } + + VerifyUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifyMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier verified successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier.", Severity.Error); + } + } + + private async Task SetPrimaryAsync(Guid id) + { + SetPrimaryUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier set successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier.", Severity.Error); + } + } + + private async Task UnsetPrimaryAsync(Guid id) + { + UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier unset successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier.", Severity.Error); + } + } + + private async Task DeleteIdentifier(Guid id) + { + DeleteUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier deleted successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor new file mode 100644 index 00000000..8e0df863 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor @@ -0,0 +1,46 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using System.Reflection + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Role Permissions + @Role.Name + + + + @* For Debug *@ + @* Current Permissions: @string.Join(", ", Role.Permissions) *@ + + @foreach (var group in _groups) + { + + + + + @group.Name (@group.Items.Count(x => x.Selected)/@group.Items.Count) + + + + + @foreach (var perm in group.Items) + { + + + + } + + + + } + + + + + Cancel + Save + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor.cs new file mode 100644 index 00000000..3f085402 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/PermissionDialog.razor.cs @@ -0,0 +1,120 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class PermissionDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public RoleInfo Role { get; set; } = default!; + + private List _groups = new(); + + protected override void OnInitialized() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + _groups = catalog + .GroupBy(p => p.Split('.')[0]) + .Select(g => new PermissionGroup + { + Name = g.Key, + Items = g.Select(p => new PermissionItem + { + Value = p, + Selected = selected.Contains(p) + }).ToList() + }) + .OrderBy(x => x.Name) + .ToList(); + } + + private void ToggleGroup(PermissionGroup group, bool value) + { + foreach (var item in group.Items) + item.Selected = value; + } + + private void TogglePermission(PermissionItem item, bool value) + { + item.Selected = value; + } + + private bool? GetGroupState(PermissionGroup group) + { + var selected = group.Items.Count(x => x.Selected); + + if (selected == 0) + return false; + + if (selected == group.Items.Count) + return true; + + return null; + } + + private async Task Save() + { + var permissions = _groups.SelectMany(g => g.Items).Where(x => x.Selected).Select(x => Permission.From(x.Value)).ToList(); + + var req = new SetRolePermissionsRequest + { + RoleId = Role.Id, + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); + + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "Failed to update permissions", Severity.Error); + return; + } + + var result2 = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name }); + if (result2.Value?.Items is not null) + { + Role = result2.Value.Items.First(); + } + + Snackbar.Add("Permissions updated", Severity.Success); + RefreshUI(); + } + + private void RefreshUI() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + foreach (var group in _groups) + { + foreach (var item in group.Items) + { + item.Selected = selected.Contains(item.Value); + } + } + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private class PermissionGroup + { + public string Name { get; set; } = ""; + public List Items { get; set; } = new(); + } + + private class PermissionItem + { + public string Value { get; set; } = ""; + public bool Selected { get; set; } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor new file mode 100644 index 00000000..d09fcfa0 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor @@ -0,0 +1,94 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + + + + + + Name + + + + + + + + + + + + + + + + + + + Personal + + + + + + + + + + + + + + + + + + + Localization + + + + + + + + + + + @foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + @tz.Id - @tz.DisplayName + } + + + + + + + + + + + + Cancel + Save + + 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 new file mode 100644 index 00000000..24fd603e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ProfileDialog.razor.cs @@ -0,0 +1,114 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class ProfileDialog +{ + private MudForm? _form; + private string? _firstName; + private string? _lastName; + private string? _displayName; + private DateTime? _birthDate; + private string? _gender; + private string? _bio; + private string? _language; + private string? _timeZone; + private string? _culture; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Users.GetMeAsync(); + } + else + { + result = await UAuthClient.Users.GetUserAsync(UserKey.Value); + } + + if (result.IsSuccess && result.Value is not null) + { + var p = result.Value; + + _firstName = p.FirstName; + _lastName = p.LastName; + _displayName = p.DisplayName; + + _gender = p.Gender; + _birthDate = p.BirthDate?.ToDateTime(TimeOnly.MinValue); + _bio = p.Bio; + + _language = p.Language; + _timeZone = p.TimeZone; + _culture = p.Culture; + } + } + + private async Task SaveAsync() + { + if (AuthState is null || AuthState.Identity is null) + { + Snackbar.Add("No AuthState found.", Severity.Error); + return; + } + + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + return; + } + + var request = new UpdateProfileRequest + { + FirstName = _firstName, + LastName = _lastName, + DisplayName = _displayName, + BirthDate = _birthDate.HasValue ? DateOnly.FromDateTime(_birthDate.Value) : null, + Gender = _gender, + Bio = _bio, + Language = _language, + TimeZone = _timeZone, + Culture = _culture + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Users.UpdateMeAsync(request); + } + else + { + result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to update profile", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor new file mode 100644 index 00000000..06a515aa --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor @@ -0,0 +1,38 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Reset Credential + + + + + + This is a demonstration of how to implement a credential reset flow. + In a production application, you should use reset token or code in email, SMS etc. verification steps. + + + Reset request always returns ok even with not found users due to security reasons. + + + Request Reset + @if (_resetRequested) + { + Your reset code is: (Copy it before next step) + @_resetCode + Use Reset Code + } + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs new file mode 100644 index 00000000..9ca66fc3 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/ResetDialog.razor.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class ResetDialog +{ + private bool _resetRequested = false; + private string? _resetCode; + private string? _identifier; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task RequestResetAsync() + { + var request = new BeginResetCredentialRequest + { + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code, + Identifier = _identifier ?? string.Empty + }; + + var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); + return; + } + + _resetCode = result.Value.Token; + _resetRequested = true; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor new file mode 100644 index 00000000..b78db16f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor @@ -0,0 +1,81 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Role Management + Manage system roles + + + + + + + + Roles + + + + + + + + + + + @GetPermissionCount(context.Item) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create + + + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor.cs new file mode 100644 index 00000000..453acb8c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/RoleDialog.razor.cs @@ -0,0 +1,164 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class RoleDialog +{ + private MudDataGrid? _grid; + private bool _loading; + private string? _newRoleName; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new RoleQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Authorization.QueryRolesAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.ErrorText ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task CommittedItemChanges(RoleInfo role) + { + var req = new RenameRoleRequest + { + Id = role.Id, + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Rename failed", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task CreateRole() + { + if (string.IsNullOrWhiteSpace(_newRoleName)) + { + Snackbar.Add("Role name required.", Severity.Warning); + return; + } + + var req = new CreateRoleRequest + { + Name = _newRoleName + }; + + var res = await UAuthClient.Authorization.CreateRoleAsync(req); + + if (res.IsSuccess) + { + Snackbar.Add("Role created.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(res.ErrorText ?? "Creation failed.", Severity.Error); + } + } + + private async Task DeleteRole(RoleId roleId) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Delete role", + "Are you sure?", + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + return; + + var req = new DeleteRoleRequest() { Id = roleId }; + var result = await UAuthClient.Authorization.DeleteRoleAsync(req); + + if (result.IsSuccess) + { + Snackbar.Add($"Role deleted, assignments removed from {result.Value?.RemovedAssignments.ToString() ?? "unknown"} users.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Deletion failed.", Severity.Error); + } + } + + private async Task EditPermissions(RoleInfo role) + { + var dialog = await DialogService.ShowAsync( + "Edit Permissions", + new DialogParameters + { + { nameof(PermissionDialog.Role), role } + }, + new DialogOptions + { + CloseButton = true, + MaxWidth = MaxWidth.Large, + FullWidth = true + }); + + var result = await dialog.Result; + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + _loading = true; + await Task.Delay(300); + if (_grid is null) + return; + + await _grid.ReloadServerData(); + _loading = false; + } + + private int GetPermissionCount(RoleInfo role) + { + var expanded = PermissionExpander.Expand(role.Permissions, UAuthPermissionCatalog.GetAdminPermissions()); + return expanded.Count; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor new file mode 100644 index 00000000..8ecf2a15 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor @@ -0,0 +1,217 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Session Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + @if (_chainDetail is not null) + { + + + + Device Details + + + + @if (!_chainDetail.IsRevoked) + { + + Revoke Device + + } + + + + + + + Device Type + @_chainDetail.DeviceType + + + + Platform + @_chainDetail.Platform + + + + Operating System + @_chainDetail.OperatingSystem + + + + Browser + @_chainDetail.Browser + + + + Created + @_chainDetail.CreatedAt.ToLocalTime() + + + + Last Seen + @_chainDetail.LastSeenAt.ToLocalTime() + + + + State + + @_chainDetail.State + + + + + Active Session + @_chainDetail.ActiveSessionId + + + + Rotation Count + @_chainDetail.RotationCount + + + + Touch Count + @_chainDetail.TouchCount + + + + + + Session History + + + + Session Id + Created + Expires + Status + + + + @context.SessionId + @context.CreatedAt.ToLocalTime() + @context.ExpiresAt.ToLocalTime() + + @if (context.IsRevoked) + { + Revoked + } + else + { + Active + } + + + + + } + else + { + + Logout All Devices + @if (UserKey == null) + { + Logout Other Devices + } + Revoke All Devices + @if (UserKey == null) + { + Revoke Other Devices + } + + + + Sessions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.ChainId + + + + Created At + @context.Item.CreatedAt + + + + Touch Count + @context.Item.TouchCount + + + + Rotation Count + @context.Item.RotationCount + + + + + + + + + } + + + Cancel + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor.cs new file mode 100644 index 00000000..17af4337 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/SessionDialog.razor.cs @@ -0,0 +1,284 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class SessionDialog +{ + private MudDataGrid? _grid; + private bool _loading = false; + private bool _reloadQueued; + private SessionChainDetail? _chainDetail; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + var result = await UAuthClient.Sessions.GetMyChainsAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + StateHasChanged(); + } + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + UAuthResult> res; + + if (UserKey is null) + { + res = await UAuthClient.Sessions.GetMyChainsAsync(req); + } + else + { + res = await UAuthClient.Sessions.GetUserChainsAsync(UserKey.Value, req); + } + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task LogoutAllAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + if (UserKey is null) + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutDeviceAsync(SessionChainId chainId) + { + LogoutDeviceRequest request = new() { ChainId = chainId }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of device.", Severity.Success); + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeAllAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); + } + else + { + result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(UserKey.Value); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + + if (UserKey is null) + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeOthersAsync() + { + var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Revoked all other devices.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeChainAsync(SessionChainId chainId) + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); + } + else + { + result = await UAuthClient.Sessions.RevokeUserChainAsync(UserKey.Value, chainId); + } + + if (result.IsSuccess) + { + Snackbar.Add("Device revoked successfully.", Severity.Success); + + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task ShowChainDetailsAsync(SessionChainId chainId) + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); + } + else + { + result = await UAuthClient.Sessions.GetUserChainDetailAsync(UserKey.Value, chainId); + } + + if (result.IsSuccess) + { + _chainDetail = result.Value; + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to fetch chain details.", Severity.Error); + _chainDetail = null; + } + } + + private void ClearDetail() + { + _chainDetail = null; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor new file mode 100644 index 00000000..52b9c527 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor @@ -0,0 +1,75 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + @_user?.UserKey.Value + + + + + + + + Display Name + @_user?.DisplayName + + + + Username + @_user?.UserName + + + + Email + @_user?.PrimaryEmail + + + + Phone + @_user?.PrimaryPhone + + + + Created + @_user?.CreatedAt?.ToLocalTime() + + + + Status + @_user?.Status + + + @foreach (var s in Enum.GetValues()) + { + @s + } + + Change + + + + + + + + Management + + Sessions + Profile + Identifiers + Credentials + Roles + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor.cs new file mode 100644 index 00000000..7228a3d2 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserDetailDialog.razor.cs @@ -0,0 +1,100 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Common; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class UserDetailDialog +{ + private UserView? _user; + private AdminAssignableUserStatus _status; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + var result = await UAuthClient.Users.GetUserAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; + } + } + + private async Task OpenSessions() + { + await DialogService.ShowAsync("Session Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenProfile() + { + await DialogService.ShowAsync("Profile Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenIdentifiers() + { + await DialogService.ShowAsync("Identifier Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenCredentials() + { + await DialogService.ShowAsync("Credentials", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenRoles() + { + await DialogService.ShowAsync("Roles", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task ChangeStatusAsync() + { + if (_user is null) + return; + + ChangeUserStatusAdminRequest request = new() + { + NewStatus = _status + }; + + var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status.ToUserStatus() }; + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + } + + private Color GetStatusColor(UserStatus? status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor new file mode 100644 index 00000000..6e754848 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor @@ -0,0 +1,49 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + + User Roles + UserKey: @UserKey.Value + + + + + Assigned Roles + + @if (_roles.Count == 0) + { + No roles assigned + } + + + @foreach (var role in _roles) + { + @role + } + + + + + Add Role + + + + @foreach (var role in _allRoles) + { + @role.Name + } + + + Add + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor.cs new file mode 100644 index 00000000..21683f5b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UserRoleDialog.razor.cs @@ -0,0 +1,124 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class UserRoleDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } = default!; + + private List _roles = new(); + private List _allRoles = new(); + + private string? _selectedRole; + + protected override async Task OnInitializedAsync() + { + await LoadRoles(); + } + + private async Task LoadRoles() + { + var userRoles = await UAuthClient.Authorization.GetUserRolesAsync(UserKey); + + if (userRoles.IsSuccess && userRoles.Value != null) + _roles = userRoles.Value.Roles.Items.Select(x => x.Name).ToList(); + + var roles = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery + { + PageNumber = 1, + PageSize = 200 + }); + + if (roles.IsSuccess && roles.Value != null) + _allRoles = roles.Value.Items.ToList(); + } + + private async Task AddRole() + { + if (string.IsNullOrWhiteSpace(_selectedRole)) + return; + + var request = new AssignRoleRequest + { + UserKey = UserKey, + RoleName = _selectedRole + }; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + + _selectedRole = null; + } + + private async Task RemoveRole(string role) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Remove Role", + $"Remove {role} from user?", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + + if (role == "Admin") + { + var confirm2 = await DialogService.ShowMessageBoxAsync( + "Are You Sure", + "You are going to remove admin role. This action may cause the application unuseable.", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm2 != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + } + + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed.", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + } + + private void Close() => MudDialog.Close(); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor new file mode 100644 index 00000000..bf19af4e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor @@ -0,0 +1,85 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + Browse, create and manage users + + + + + + + + + + + + + + + + + Users + + New User + + + + + + + + + + + @context.Item.Status + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.UserKey.Value + + + + Created At + @context.Item.CreatedAt + + + + + + + + + + + + + Close + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs new file mode 100644 index 00000000..3168085d --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Dialogs/UsersDialog.razor.cs @@ -0,0 +1,176 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Common; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; + +public partial class UsersDialog +{ + private MudDataGrid? _grid; + private bool _loading; + private string? _search; + private bool _reloadQueued; + private UserStatus? _statusFilter; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task> LoadUsers(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new UserQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + Search = _search, + Status = _statusFilter, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Users.QueryAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.ErrorText ?? "Failed to load users.", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task OnStatusChanged(UserStatus? status) + { + _statusFilter = status; + await ReloadAsync(); + } + + private async Task OpenUser(UserKey userKey) + { + var dialog = await DialogService.ShowAsync("User", UAuthDialog.GetDialogParameters(AuthState, userKey), UAuthDialog.GetDialogOptions()); + await dialog.Result; + await ReloadAsync(); + } + + private async Task OpenCreateUser() + { + var dialog = await DialogService.ShowAsync( + "Create User", + new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseButton = true + }); + + var result = await dialog.Result; + + if (result?.Canceled == false) + await ReloadAsync(); + } + + private async Task DeleteUserAsync(UserSummary user) + { + var confirm = await DialogService.ShowMessageBoxAsync( + title: "Delete user", + markupMessage: (MarkupString)$""" + Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.PrimaryEmail ?? user.UserKey}? +

+ This operation is intended for admin usage. + """, + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (confirm != true) + return; + + var req = new DeleteUserRequest + { + Mode = DeleteMode.Soft + }; + + var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + + if (result.IsSuccess) + { + Snackbar.Add("User deleted successfully.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to delete user.", Severity.Error); + } + } + + private static Color GetStatusColor(UserStatus status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.SelfSuspended => Color.Warning, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor new file mode 100644 index 00000000..7239bf28 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor @@ -0,0 +1,65 @@ +๏ปฟ@inherits LayoutComponentBase +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject NavigationManager Nav + + + + + UltimateAuth + + Blazor Server Sample + + + + + + + + + +
+ + + @((state.Identity?.DisplayName ?? "?").Trim() is var n ? (n.Length >= 2 ? n[..2] : n[..1]) : "?") + + +
+
+ + + @state.Identity?.DisplayName + @string.Join(", ", state.Claims.Roles) + + + + + + + + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) + { + + + } + +
+
+ + + + +
+
+ + + @Body + +
+ + +
+ An unhandled error has occurred. + Reload + ๐Ÿ—™ +
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs new file mode 100644 index 00000000..47d68df7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.cs @@ -0,0 +1,130 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Layout; + +public partial class MainLayout +{ + [CascadingParameter] + public UAuthState UAuth { get; set; } = default!; + + [CascadingParameter] + public DarkModeManager DarkModeManager { get; set; } = default!; + + private async Task Refresh() + { + await UAuthClient.Flows.RefreshAsync(); + } + + private async Task Logout() + { + await UAuthClient.Flows.LogoutAsync(); + } + + private Color GetBadgeColor() + { + if (UAuth is null || !UAuth.IsAuthenticated) + return Color.Error; + + if (UAuth.IsStale) + return Color.Warning; + + var state = UAuth.Identity?.SessionState; + + if (state is null || state == SessionState.Active) + return Color.Success; + + if (state == SessionState.Invalid) + return Color.Error; + + return Color.Warning; + } + + private void HandleSignInClick() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + Nav.NavigateTo("/login?focus=1", replace: true, forceLoad: true); + return; + } + + GoToLoginWithReturn(); + } + + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active โ€ข Tenant: {result.Snapshot?.Identity?.Tenant.Value} โ€ข User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + + private void GoToLoginWithReturn() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + Nav.NavigateTo("/login", replace: true); + return; + } + + var current = Nav.ToBaseRelativePath(uri.ToString()); + if (string.IsNullOrWhiteSpace(current)) + current = "home"; + + var returnUrl = Uri.EscapeDataString("/" + current.TrimStart('/')); + Nav.NavigateTo($"/login?returnUrl={returnUrl}", replace: true); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css new file mode 100644 index 00000000..df8c10ff --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Layout/MainLayout.razor.css @@ -0,0 +1,18 @@ +#blazor-error-ui { + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AnonymousTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AnonymousTestPage.razor new file mode 100644 index 00000000..10d035ba --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AnonymousTestPage.razor @@ -0,0 +1 @@ +๏ปฟ@page "/anonymous-test" diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor new file mode 100644 index 00000000..5dc5d8aa --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/AuthorizedTestPage.razor @@ -0,0 +1,26 @@ +๏ปฟ@page "/authorized-test" +@attribute [Authorize] + + + + + + + Everything is Ok + + + If you see this section, it means you succesfully logged in. + + + + Go Profile + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor new file mode 100644 index 00000000..576cc2d2 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +๏ปฟ@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor new file mode 100644 index 00000000..b71e9282 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor @@ -0,0 +1,444 @@ +๏ปฟ@page "/home" +@attribute [Authorize] +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject UAuthClientDiagnostics Diagnostics +@inject AuthenticationStateProvider AuthStateProvider +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Security.Claims +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Custom +@using Microsoft.AspNetCore.Authorization + +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + + + + Set Active + Logout + + + + return; +} + +@if (AuthState?.Identity?.UserStatus == UserStatus.Suspended) +{ + + + + Your account is suspended. Please contact with administrator. + + + + Logout + + + + return; +} + + + + + + + + + + + Session + + + + + + + Validate + + + + + + Manual Refresh + + + + + + Logout + + + + + + Account + + + + + Manage Sessions + + + + Manage Profile + + + + Manage Identifiers + + + + Manage Credentials + + + + Suspend | Delete Account + + + + Admin + + + + + + + + + @if (_showAdminPreview) + { + + Admin operations are shown for preview. Sign in as an Admin to execute them. + + } + + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + { + + + + @* *@ + @* *@ + User Management + @* *@ + + + + + + @* *@ + Role Management + @* *@ + + + + } + + + + + + + + + + + @((AuthState?.Identity?.DisplayName ?? "?").Substring(0, Math.Min(2, (AuthState?.Identity?.DisplayName ?? "?").Length))) + + + + @AuthState?.Identity?.DisplayName + + @foreach (var role in AuthState?.Claims?.Roles ?? Enumerable.Empty()) + { + + @role + + } + + + + + + + + + + @if (_selectedAuthState == "UAuthState") + { + + +
+ + + Tenant + + @AuthState?.Identity?.Tenant.Value +
+ +
+ + +
+ + + User Id + + @AuthState?.Identity?.UserKey.Value +
+
+ + +
+ + + Authenticated + + @(AuthState?.IsAuthenticated == true ? "Yes" : "No") +
+
+ + +
+ + + Session State + + @AuthState?.Identity?.SessionState?.ToDescriptionString() +
+
+ + +
+ + + Username + + @AuthState?.Identity?.PrimaryUserName +
+
+ + +
+ + + Display Name + + @AuthState?.Identity?.DisplayName +
+
+ + + + + + + Email + + @AuthState?.Identity?.PrimaryEmail + + + + + + Phone + + @AuthState?.Identity?.PrimaryPhone + + + + + + + + Authenticated At + + @* TODO: Add IUAuthDateTimeFormatter *@ + @FormatLocalTime(AuthState?.Identity?.AuthenticatedAt) + + + + + + Last Validated At + + @* TODO: Validation call should update last validated at *@ + @FormatLocalTime(AuthState?.LastValidatedAt) + +
+ } + else if (_selectedAuthState == "AspNetCoreState") + { + + +
+ + + Authenticated + + @(_aspNetCoreState?.Identity?.IsAuthenticated == true ? "Yes" : "No") +
+
+ + +
+ + + User Id + + @_aspNetCoreState?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value +
+
+ + +
+ + + Username + + @_aspNetCoreState?.Identity?.Name +
+
+ + +
+ + + Authentication Type + + @_aspNetCoreState?.Identity?.AuthenticationType +
+
+
+ } +
+
+
+ + + + + + @GetHealthText() + + + Lifecycle + + + + + + Started + @Diagnostics.StartCount + + @if (Diagnostics.StartedAt is not null) + { + + + + @FormatRelative(Diagnostics.StartedAt) + + + } + + + + + Stopped + @Diagnostics.StopCount + + + + + + Terminated + @Diagnostics.TerminatedCount + + @if (Diagnostics.TerminatedAt is not null) + { + + + + + @FormatRelative(Diagnostics.TerminatedAt) + + + + } + + + + + + Refresh Metrics + + + + + + + Total Attempts + @Diagnostics.RefreshAttemptCount + + + + + + + Success + + @Diagnostics.RefreshSuccessCount + + + + + + Automatic + @Diagnostics.AutomaticRefreshCount + + + + + + Manual + @Diagnostics.ManualRefreshCount + + + + + + Touched/Rotated + @Diagnostics.RefreshTouchedCount / @Diagnostics.RefreshRotatedCount + + + + + + No-Op + @Diagnostics.RefreshNoOpCount + + + + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + + + + + + +
+
+
diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs new file mode 100644 index 00000000..3faeca19 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Home.razor.cs @@ -0,0 +1,222 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Common; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Home : UAuthFlowPageBase +{ + private string _selectedAuthState = "UAuthState"; + private ClaimsPrincipal? _aspNetCoreState; + + private bool _showAdminPreview = false; + + protected override async Task OnInitializedAsync() + { + var initial = await AuthStateProvider.GetAuthenticationStateAsync(); + _aspNetCoreState = initial.User; + AuthStateProvider.AuthenticationStateChanged += OnAuthStateChanged; + Diagnostics.Changed += OnDiagnosticsChanged; + } + + private void OnAuthStateChanged(Task task) + { + _ = HandleAuthStateChangedAsync(task); + } + + private async Task HandleAuthStateChangedAsync(Task task) + { + try + { + var state = await task; + _aspNetCoreState = state.User; + await InvokeAsync(StateHasChanged); + } + catch + { + + } + } + + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task Logout() => await UAuthClient.Flows.LogoutAsync(); + + private async Task RefreshSession() => await UAuthClient.Flows.RefreshAsync(false); + + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active โ€ข Tenant: {result.Snapshot?.Identity?.Tenant.Value} โ€ข User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + + private Color GetHealthColor() + { + if (Diagnostics.RefreshReauthRequiredCount > 0) + return Color.Warning; + + if (Diagnostics.TerminatedCount > 0) + return Color.Error; + + return Color.Success; + } + + private string GetHealthText() + { + if (Diagnostics.RefreshReauthRequiredCount > 0) + return "Reauthentication Required"; + + if (Diagnostics.TerminatedCount > 0) + return "Session Terminated"; + + return "Healthy"; + } + + private string? FormatRelative(DateTimeOffset? utc) + { + if (utc is null) + return null; + + var diff = DateTimeOffset.UtcNow - utc.Value; + + if (diff.TotalSeconds < 5) + return "just now"; + + if (diff.TotalSeconds < 60) + return $"{(int)diff.Seconds} secs ago"; + + if (diff.TotalMinutes < 60) + return $"{(int)diff.TotalMinutes} min ago"; + + if (diff.TotalHours < 24) + return $"{(int)diff.TotalHours} hrs ago"; + + return utc.Value.ToLocalTime().ToString("dd MMM yyyy"); + } + + private string? FormatLocalTime(DateTimeOffset? utc) + { + return utc?.ToLocalTime().ToString("dd MMM yyyy โ€ข HH:mm:ss"); + } + + private async Task OpenProfileDialog() + { + await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenIdentifierDialog() + { + await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenSessionDialog() + { + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenCredentialDialog() + { + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenAccountStatusDialog() + { + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.ExtraSmall)); + } + + private async Task OpenUserDialog() + { + await DialogService.ShowAsync("User Management", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.Large)); + } + + private async Task OpenRoleDialog() + { + await DialogService.ShowAsync("Role Management", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + + private async Task SetAccountActiveAsync() + { + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.Active }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Account activated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Activation failed.", Severity.Error); + } + } + + public override void Dispose() + { + base.Dispose(); + AuthStateProvider.AuthenticationStateChanged -= OnAuthStateChanged; + Diagnostics.Changed -= OnDiagnosticsChanged; + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor new file mode 100644 index 00000000..1e4a9016 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor @@ -0,0 +1,4 @@ +๏ปฟ@page "/" + +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthProvider diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs new file mode 100644 index 00000000..ab16ba5e --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/LandingPage.razor.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class LandingPage +{ + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var state = await AuthProvider.GetAuthenticationStateAsync(); + var isAuthenticated = state.User.Identity?.IsAuthenticated == true; + + Nav.NavigateTo(isAuthenticated ? "/home" : $"{UAuthConstants.Routes.LoginRedirect}?fresh=true"); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor new file mode 100644 index 00000000..f1d587c7 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor @@ -0,0 +1,126 @@ +๏ปฟ@page "/login" +@attribute [UAuthLoginPage] +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..0cbc8441 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Login.razor.cs @@ -0,0 +1,211 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Dialogs; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Login : UAuthFlowPageBase +{ + private string? _username; + private string? _password; + private UAuthClientProductInfo? _productInfo; + private MudTextField _usernameField = default!; + + private CancellationTokenSource? _lockoutCts; + private PeriodicTimer? _lockoutTimer; + private DateTimeOffset? _lockoutUntil; + private TimeSpan _remaining; + private bool _isLocked; + private DateTimeOffset? _lockoutStartedAt; + private TimeSpan _lockoutDuration; + private double _progressPercent; + private int? _remainingAttempts = null; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + protected override Task OnUAuthPayloadAsync(AuthFlowPayload payload) + { + HandleLoginPayload(payload); + return Task.CompletedTask; + } + + protected override async Task OnFocusRequestedAsync() + { + await _usernameField.FocusAsync(); + } + + private void HandleLoginPayload(AuthFlowPayload payload) + { + if (payload.Flow != AuthFlowType.Login) + return; + + if (payload.Reason == AuthFailureReason.LockedOut && payload.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = payload.RemainingAttempts; + ShowLoginError(payload.Reason, payload.RemainingAttempts); + } + + private void ShowLoginError(AuthFailureReason? reason, int? remainingAttempts) + { + string message = reason switch + { + AuthFailureReason.InvalidCredentials when remainingAttempts is > 0 + => $"Invalid username or password. {remainingAttempts} attempt(s) remaining.", + + AuthFailureReason.InvalidCredentials + => "Invalid username or password.", + + AuthFailureReason.RequiresMfa + => "Multi-factor authentication required.", + + AuthFailureReason.LockedOut + => "Your account is locked.", + + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private async Task ProgrammaticLogin() + { + var request = new LoginRequest + { + Identifier = "admin", + Secret = "admin", + }; + await UAuthClient.Flows.LoginAsync(request, ReturnUrl ?? "/home"); + } + + private async void StartCountdown() + { + if (_lockoutUntil is null) + return; + + _isLocked = true; + _lockoutStartedAt = DateTimeOffset.UtcNow; + _lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow; + UpdateRemaining(); + + _lockoutCts?.Cancel(); + _lockoutCts = new CancellationTokenSource(); + + _lockoutTimer?.Dispose(); + _lockoutTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + while (await _lockoutTimer.WaitForNextTickAsync(_lockoutCts.Token)) + { + UpdateRemaining(); + + if (_remaining <= TimeSpan.Zero) + { + ResetLockoutState(); + await InvokeAsync(StateHasChanged); + break; + } + + await InvokeAsync(StateHasChanged); + } + } + catch (OperationCanceledException) + { + + } + } + + private void ResetLockoutState() + { + _isLocked = false; + _lockoutUntil = null; + _progressPercent = 0; + _remainingAttempts = null; + } + + private void UpdateRemaining() + { + if (_lockoutUntil is null || _lockoutStartedAt is null) + return; + + var now = DateTimeOffset.UtcNow; + + _remaining = _lockoutUntil.Value - now; + + if (_remaining <= TimeSpan.Zero) + { + _remaining = TimeSpan.Zero; + return; + } + + var elapsed = now - _lockoutStartedAt.Value; + + if (_lockoutDuration.TotalSeconds > 0) + { + var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100); + _progressPercent = Math.Max(0, percent); + } + } + + private void HandleTry(IUAuthTryResult result) + { + if (result is TryLoginResult pkce) + { + if (!result.Success) + { + if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = result.RemainingAttempts; + ShowLoginError(result.Reason, result.RemainingAttempts); + } + } + else + { + Snackbar.Add("Unexpected result type.", Severity.Error); + } + } + + private async Task OpenResetDialog() + { + await DialogService.ShowAsync("Reset Credentials", GetDialogParameters(), GetDialogOptions()); + } + + private DialogOptions GetDialogOptions() + { + return new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + + public override void Dispose() + { + base.Dispose(); + _lockoutCts?.Cancel(); + _lockoutTimer?.Dispose(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor new file mode 100644 index 00000000..d8eb7138 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor @@ -0,0 +1,27 @@ +๏ปฟ@inject NavigationManager Nav + + + + + + + Access Denied + + + You donโ€™t have permission to view this page. + If you think this is a mistake, sign in with a different account or request access. + + + + Sign In + Go Back + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor.cs new file mode 100644 index 00000000..9d05edad --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/NotAuthorized.razor.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class NotAuthorized +{ + private string LoginHref + { + get + { + var returnUrl = Uri.EscapeDataString(Nav.ToBaseRelativePath(Nav.Uri)); + return $"/login?returnUrl=/{returnUrl}"; + } + } + + private void GoBack() => Nav.NavigateTo("/", replace: false); +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor new file mode 100644 index 00000000..881cae5c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,60 @@ +๏ปฟ@page "/register" +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs new file mode 100644 index 00000000..d1e67865 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/Register.razor.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Users.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class Register +{ + private string? _username; + private string? _password; + private string? _passwordCheck; + private string? _email; + private UAuthClientProductInfo? _productInfo; + private MudForm _form = null!; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + private async Task HandleRegisterAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + var request = new CreateUserRequest + { + UserName = _username, + Password = _password, + Email = _email, + }; + + var result = await UAuthClient.Users.CreateAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("User created successfully.", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to create user.", Severity.Error); + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor new file mode 100644 index 00000000..753878b8 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor @@ -0,0 +1,18 @@ +๏ปฟ@page "/reset" +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + + + + + + Change Password + + + + \ No newline at end of file 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 new file mode 100644 index 00000000..db40becc --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Pages/ResetCredential.razor.cs @@ -0,0 +1,49 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages; + +public partial class ResetCredential +{ + private MudForm _form = null!; + private string? _code; + private string? _newPassword; + private string? _newPasswordCheck; + + private async Task ResetPasswordAsync() + { + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("Passwords do not match.", Severity.Error); + return; + } + + var request = new CompleteResetCredentialRequest + { + ResetToken = _code, + NewSecret = _newPassword ?? string.Empty, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + }; + + var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to reset credential. Please try again.", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor new file mode 100644 index 00000000..6586c3fc --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/Routes.razor @@ -0,0 +1,73 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Sample.BlazorServer.Components.Pages +@using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + @* Advanced: you can fully control routing by providing your own Router *@ + @* + + + + + + + + + + + + + + + + *@ + + +@code { + private async Task HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } + + #region DarkMode + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + private void OnThemeChanged() + { + InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await DarkModeManager.InitializeAsync(); + StateHasChanged(); + } + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } + + #endregion +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor new file mode 100644 index 00000000..2c3cb6dd --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Components/_Imports.razor @@ -0,0 +1,22 @@ +๏ปฟ@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.JSInterop +@using UltimateAuth.Sample.BlazorServer +@using UltimateAuth.Sample.BlazorServer.Components + +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Blazor + +@using MudBlazor +@using MudExtensions diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs new file mode 100644 index 00000000..9afcf32f --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Infrastructure/DarkModeManager.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; + +public sealed class DarkModeManager +{ + private const string StorageKey = "uauth:theme:dark"; + + private readonly IClientStorage _storage; + + public DarkModeManager(IClientStorage storage) + { + _storage = storage; + } + + public async Task InitializeAsync() + { + var value = await _storage.GetAsync(StorageScope.Local, StorageKey); + + if (bool.TryParse(value, out var parsed)) + IsDarkMode = parsed; + } + + public bool IsDarkMode { get; set; } + + public event Action? Changed; + + public async Task ToggleAsync() + { + IsDarkMode = !IsDarkMode; + + await _storage.SetAsync(StorageScope.Local, StorageKey, IsDarkMode.ToString()); + Changed?.Invoke(); + } + + public void Set(bool value) + { + if (IsDarkMode == value) + return; + + IsDarkMode = value; + Changed?.Invoke(); + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs new file mode 100644 index 00000000..7b54009b --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Program.cs @@ -0,0 +1,103 @@ +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Components; +using CodeBeam.UltimateAuth.Sample.BlazorServer.Infrastructure; +using CodeBeam.UltimateAuth.Sample.Seed.Extensions; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.HttpOverrides; +using MudBlazor.Services; +using MudExtensions.Services; +using Scalar.AspNetCore; + +var builder = WebApplication.CreateBuilder(args); + +#region Core + +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents() + .AddCircuitOptions(options => + { + options.DetailedErrors = true; + }); + +builder.Services.AddOpenApi(); + +#endregion + +# region UI & MudBlazor & Extensions + +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); +builder.Services.AddMudExtensions(); +builder.Services.AddScoped(); + +#endregion + + +builder.Services.AddUltimateAuthServer(o => +{ + o.Diagnostics.EnableRefreshDetails = true; + //o.Session.MaxLifetime = TimeSpan.FromSeconds(32); + //o.Session.Lifetime = TimeSpan.FromSeconds(32); + //o.Session.TouchInterval = TimeSpan.FromSeconds(9); + //o.Session.IdleTimeout = TimeSpan.FromSeconds(15); + //o.Token.AccessTokenLifetime = TimeSpan.FromSeconds(30); + //o.Token.RefreshTokenLifetime = TimeSpan.FromSeconds(32); + o.Login.MaxFailedAttempts = 2; + o.Login.LockoutDuration = TimeSpan.FromSeconds(10); + o.Identifiers.AllowMultipleUsernames = true; +}) + .AddUltimateAuthInMemory(); + +builder.Services.AddUltimateAuthClientBlazor(o => +{ + //o.AutoRefresh.Interval = TimeSpan.FromSeconds(5); + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + //o.UAuthStateRefreshMode = UAuthStateRefreshMode.Validate; +}); + +builder.Services.AddUltimateAuthSampleSeed(); + +builder.Services.Configure(options => +{ + options.ForwardedHeaders = + ForwardedHeaders.XForwardedFor | + ForwardedHeaders.XForwardedProto; +}); + +var app = builder.Build(); + +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + app.UseHsts(); +} +else +{ + app.MapOpenApi(); + app.MapScalarApiReference(); + + using var scope = app.Services.CreateScope(); + var seedRunner = scope.ServiceProvider.GetRequiredService(); + await seedRunner.RunAsync(null); +} + +app.UseForwardedHeaders(); + +app.UseHttpsRedirection(); +app.UseStaticFiles(); + +app.UseUltimateAuthWithAspNetCore(); +app.UseAntiforgery(); + +app.MapUltimateAuthEndpoints(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddUltimateAuthRoutes(UAuthAssemblies.BlazorClient()); + +app.Run(); diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json new file mode 100644 index 00000000..e5dc5589 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:8585", + "sslPort": 44364 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5098", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7213;http://localhost:5098", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.Development.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.json b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/UltimateAuth-Logo.png b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/UltimateAuth-Logo.png new file mode 100644 index 00000000..5b7282f1 Binary files /dev/null and b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/UltimateAuth-Logo.png differ diff --git a/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css new file mode 100644 index 00000000..2b9a4745 --- /dev/null +++ b/samples/blazor-server/CodeBeam.UltimateAuth.Sample.BlazorServer/wwwroot/app.css @@ -0,0 +1,143 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +a, .btn-link { + color: #006bb7; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.uauth-stack { + min-height: 60vh; + max-height: calc(100vh - var(--mud-appbar-height)); + width: 30vw; + min-width: 300px; +} + +.uauth-menu-popover { + width: 300px; +} + +.uauth-login-paper { + min-height: 70vh; +} + +.uauth-login-paper.mud-theme-primary { + background: linear-gradient(145deg, var(--mud-palette-primary), rgba(0, 0, 0, 0.85) ); + color: white; +} + +.uauth-brand-glow { + filter: drop-shadow(0 0 25px rgba(255,255,255,0.15)); +} + +.uauth-logo-slide { + animation: uauth-logo-float 30s ease-in-out infinite; +} + +.uauth-text-transform-none .mud-button { + text-transform: none; +} + +.uauth-dialog { + height: 68vh; + max-height: 68vh; + overflow: auto; +} + +.text-secondary { + color: var(--mud-palette-text-secondary); +} + +.uauth-blur { + backdrop-filter: blur(10px); +} + +.uauth-blur-slight { + backdrop-filter: blur(4px); +} + +@keyframes uauth-logo-float { + 0% { + transform: translateY(0) rotateY(0); + } + + 10% { + transform: translateY(0) rotateY(0); + } + + 15% { + transform: translateY(200px) rotateY(360deg); + } + + 35% { + transform: translateY(200px) rotateY(360deg); + } + + 40% { + transform: translateY(200px) rotateY(720deg); + } + + 60% { + transform: translateY(200px) rotateY(720deg); + } + + 65% { + transform: translateY(0) rotateY(360deg); + } + + 85% { + transform: translateY(0) rotateY(360deg); + } + + 90% { + transform: translateY(0) rotateY(0); + } + + 100% { + transform: translateY(0) rotateY(0); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor new file mode 100644 index 00000000..783b707b --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/App.razor @@ -0,0 +1,73 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages +@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure +@inject ISnackbar Snackbar +@inject DarkModeManager DarkModeManager + + + + + + + + + + + + + + + + @* Advanced: you can fully control routing by providing your own Router *@ + @* + + + + + + + + + + + + + + + + *@ + + +@code { + private async Task HandleReauth() + { + Snackbar.Add("Reauthentication required. Please log in again.", Severity.Warning); + } + + #region DarkMode + + protected override void OnInitialized() + { + DarkModeManager.Changed += OnThemeChanged; + } + + private void OnThemeChanged() + { + InvokeAsync(StateHasChanged); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await DarkModeManager.InitializeAsync(); + StateHasChanged(); + } + } + + public void Dispose() + { + DarkModeManager.Changed -= OnThemeChanged; + } + + #endregion +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor new file mode 100644 index 00000000..2806b7d3 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor @@ -0,0 +1,19 @@ +๏ปฟ@namespace CodeBeam.UltimateAuth.Sample +@inherits ComponentBase + + + + @if (Variant == UAuthLogoVariant.Brand) + { + + + + } + else + { + + + + } + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor.cs new file mode 100644 index 00000000..030d9b66 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogo.razor.cs @@ -0,0 +1,54 @@ +๏ปฟusing Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Web; + +namespace CodeBeam.UltimateAuth.Sample; + +public partial class UAuthLogo : ComponentBase +{ + [Parameter] public UAuthLogoVariant Variant { get; set; } = UAuthLogoVariant.Brand; + + [Parameter] public int Size { get; set; } = 32; + + [Parameter] public string? ShieldColor { get; set; } = "#00072d"; + [Parameter] public string? KeyColor { get; set; } = "#f6f5ae"; + + [Parameter] public string? Class { get; set; } + [Parameter] public string? Style { get; set; } + + private string BuildStyle() + { + if (Variant == UAuthLogoVariant.Mono) + return $"color: {KeyColor}; {Style}"; + + return Style ?? ""; + } + + protected string KeyPath => @" +M120.43,39.44H79.57A11.67,11.67,0,0,0,67.9,51.11V77.37 +A11.67,11.67,0,0,0,79.57,89H90.51l3.89,3.9v5.32l-3.8,3.81v81.41H99 +v-5.33h13.69V169H108.1v-3.8H99C99,150.76,111.9,153,111.9,153 +V99.79h-8V93.32L108.19,89h12.24 +A11.67,11.67,0,0,0,132.1,77.37V51.11 +A11.67,11.67,0,0,0,120.43,39.44Z + +M79.57,48.19h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.84a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.84a2.91,2.91 0 0 1 2.91,-2.92Z + +M79.57,68.62h5.84a2.92,2.92 0 0 1 2.92,2.92 +v5.83a2.92,2.92 0 0 1 -2.92,2.92 +h-5.84a2.91,2.91 0 0 1 -2.91,-2.92 +v-5.83a2.91,2.91 0 0 1 2.91,-2.92Z + +M114.59,48.19h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.84a2.91,2.91 0 0 1 -2.91,2.91 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.91 +v-5.84a2.92,2.92 0 0 1 2.92,-2.92Z + +M114.59,68.62h5.84a2.92,2.92 0 0 1 2.91,2.92 +v5.83a2.91,2.91 0 0 1 -2.91,2.92 +h-5.84a2.92,2.92 0 0 1 -2.92,-2.92 +v-5.83a2.92,2.92 0 0 1 2.92,-2.92Z +"; +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogoVariant.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogoVariant.cs new file mode 100644 index 00000000..fe3be220 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Brand/UAuthLogoVariant.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample; + +public enum UAuthLogoVariant +{ + Brand, + Mono +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj new file mode 100644 index 00000000..06ddc3f2 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.csproj @@ -0,0 +1,24 @@ +๏ปฟ + + + net10.0 + enable + enable + 0.1.0 + false + + + + + + + + + + + + + + + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Common/UAuthDialog.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Common/UAuthDialog.cs new file mode 100644 index 00000000..5e52a199 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Common/UAuthDialog.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Common; + +public static class UAuthDialog +{ + public static DialogParameters GetDialogParameters(UAuthState state, UserKey? userKey = null) + { + DialogParameters parameters = new DialogParameters(); + parameters.Add("AuthState", state); + if (userKey != null ) + { + parameters.Add("UserKey", userKey); + } + return parameters; + } + + public static DialogOptions GetDialogOptions(MaxWidth maxWidth = MaxWidth.Medium) + { + return new DialogOptions + { + MaxWidth = maxWidth, + FullWidth = true, + CloseButton = true + }; + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Custom/UAuthPageComponent.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Custom/UAuthPageComponent.razor new file mode 100644 index 00000000..5af543e4 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Custom/UAuthPageComponent.razor @@ -0,0 +1,10 @@ +๏ปฟ + + @ChildContent + + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor new file mode 100644 index 00000000..0c91e45c --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor @@ -0,0 +1,23 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + User: @AuthState?.Identity?.DisplayName + + + + + Suspend Account + + + + Delete Account + + + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor.cs new file mode 100644 index 00000000..0bd13951 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/AccountStatusDialog.razor.cs @@ -0,0 +1,77 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class AccountStatusDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task SuspendAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to suspend your account.

+ You can still active your account later. + """, + yesText: "Suspend", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Suspend process cancelled.", Severity.Info); + return; + } + + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.SelfSuspended }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("Your account suspended successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + } + } + + private async Task DeleteAccountAsync() + { + var info = await DialogService.ShowMessageBoxAsync( + title: "Are You Sure", + markupMessage: (MarkupString) + """ + You are going to delete your account.

+ This action can't be undone.

+ (Actually it is, admin can handle soft deleted accounts.) + """, + yesText: "Delete", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (info != true) + { + Snackbar.Add("Deletion cancelled.", Severity.Info); + return; + } + + var result = await UAuthClient.Users.DeleteMeAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Your account deleted successfully.", Severity.Success); + MudDialog.Close(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Delete failed.", Severity.Error); + } + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor new file mode 100644 index 00000000..9a514935 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor @@ -0,0 +1,27 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Create User + + + + + + + + + + + + + + + + Cancel + Create + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor.cs new file mode 100644 index 00000000..61aa56fb --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CreateUserDialog.razor.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class CreateUserDialog +{ + private MudForm _form = null!; + private string? _username; + private string? _email; + private string? _password; + private string? _passwordCheck; + private string? _displayName; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + private async Task CreateUserAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + if (_password != _passwordCheck) + { + Snackbar.Add("Passwords don't match.", Severity.Error); + return; + } + + var request = new CreateUserRequest + { + UserName = _username, + Email = _email, + DisplayName = _displayName, + Password = _password + }; + + var result = await UAuthClient.Users.CreateAsAdminAsync(request); + + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "User creation failed.", Severity.Error); + return; + } + + Snackbar.Add("User created successfully", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + + private string PasswordMatch(string? arg) => _password != arg ? "Passwords don't match." : string.Empty; + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor new file mode 100644 index 00000000..660b7c3a --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor @@ -0,0 +1,51 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Credential Management + User: @AuthState?.Identity?.DisplayName + + + + + @if (UserKey == null) + { + + + + } + else + { + + + Administrators can directly assign passwords to users. + However, using the credential reset flow is generally recommended for better security and auditability. + + + } + + + + + + + + + + + @(UserKey is null ? "Change Password" : "Set Password") + + + + + + Cancel + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs new file mode 100644 index 00000000..926eba3d --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/CredentialDialog.razor.cs @@ -0,0 +1,92 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class CredentialDialog +{ + private MudForm _form = null!; + private string? _oldPassword; + private string? _newPassword; + private string? _newPasswordCheck; + private bool _passwordMode1 = false; + private bool _passwordMode2 = false; + private bool _passwordMode3 = true; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + private async Task ChangePasswordAsync() + { + if (_form is null) + return; + + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Form is not valid.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("New password and check do not match", Severity.Error); + return; + } + + ChangeCredentialRequest request; + + if (UserKey is null) + { + request = new ChangeCredentialRequest + { + CurrentSecret = _oldPassword!, + NewSecret = _newPassword! + }; + } + else + { + request = new ChangeCredentialRequest + { + NewSecret = _newPassword! + }; + } + + UAuthResult result; + if (UserKey is null) + { + result = await UAuthClient.Credentials.ChangeMyAsync(request); + } + else + { + result = await UAuthClient.Credentials.ChangeUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Password changed successfully", Severity.Success); + _oldPassword = null; + _newPassword = null; + _newPasswordCheck = null; + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.ErrorText ?? "An error occurred while changing password", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor new file mode 100644 index 00000000..24c9e8c9 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor @@ -0,0 +1,115 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + @if (_loaded) + { + + + + Identifiers + + + + + + + + + + + + + + + + + + + + + + + + + @if (context.Item.IsPrimary) + { + + + + } + else + { + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add + + + + + } + else + { +
+ +
+ } +
+ + + Cancel + +
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor.cs new file mode 100644 index 00000000..d0e89b89 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/IdentifierDialog.razor.cs @@ -0,0 +1,311 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class IdentifierDialog +{ + private MudDataGrid? _grid; + private UserIdentifierType _newIdentifierType; + private string? _newIdentifierValue; + private bool _newIdentifierPrimary; + private bool _loading = false; + private bool _reloadQueued; + private bool _loaded; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + _loaded = true; + var result = await UAuthClient.Identifiers.GetMyAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + } + StateHasChanged(); + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + UAuthResult> res; + + if (UserKey is null) + { + res = await UAuthClient.Identifiers.GetMyAsync(req); + } + else + { + res = await UAuthClient.Identifiers.GetUserAsync(UserKey.Value, req); + } + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task CommittedItemChanges(UserIdentifierInfo item) + { + UpdateUserIdentifierRequest updateRequest = new() + { + Id = item.Id, + NewValue = item.Value + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UpdateMyAsync(updateRequest); + } + else + { + result = await UAuthClient.Identifiers.UpdateUserAsync(UserKey.Value, updateRequest); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier updated successfully", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to update identifier", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task AddNewIdentifier() + { + if (string.IsNullOrEmpty(_newIdentifierValue)) + { + Snackbar.Add("Value cannot be empty", Severity.Warning); + return; + } + + AddUserIdentifierRequest request = new() + { + Type = _newIdentifierType, + Value = _newIdentifierValue, + IsPrimary = _newIdentifierPrimary + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.AddMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.AddUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier added successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to add identifier", Severity.Error); + } + } + + private async Task VerifyAsync(Guid id) + { + var demoInfo = await DialogService.ShowMessageBoxAsync( + title: "Demo verification", + markupMessage: (MarkupString) + """ + This is a demo action.

+ In a real app, you should verify identifiers via Email, SMS, or an Authenticator flow. + This will only mark the identifier as verified in UltimateAuth. + """, + yesText: "Verify", noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (demoInfo != true) + { + Snackbar.Add("Verification cancelled", Severity.Info); + return; + } + + VerifyUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.VerifyMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.VerifyUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier verified successfully", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to verify primary identifier", Severity.Error); + } + } + + private async Task SetPrimaryAsync(Guid id) + { + SetPrimaryUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.SetMyPrimaryAsync(request); + } + else + { + result = await UAuthClient.Identifiers.SetUserPrimaryAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier set successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to set primary identifier", Severity.Error); + } + } + + private async Task UnsetPrimaryAsync(Guid id) + { + UnsetPrimaryUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.UnsetMyPrimaryAsync(request); + } + else + { + result = await UAuthClient.Identifiers.UnsetUserPrimaryAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Primary identifier unset successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to unset primary identifier", Severity.Error); + } + } + + private async Task DeleteIdentifier(Guid id) + { + DeleteUserIdentifierRequest request = new() { Id = id }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Identifiers.DeleteMyAsync(request); + } + else + { + result = await UAuthClient.Identifiers.DeleteUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Identifier deleted successfully.", Severity.Success); + await ReloadAsync(); + StateHasChanged(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to delete identifier", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor new file mode 100644 index 00000000..8e0df863 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor @@ -0,0 +1,46 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using System.Reflection + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + Role Permissions + @Role.Name + + + + @* For Debug *@ + @* Current Permissions: @string.Join(", ", Role.Permissions) *@ + + @foreach (var group in _groups) + { + + + + + @group.Name (@group.Items.Count(x => x.Selected)/@group.Items.Count) + + + + + @foreach (var perm in group.Items) + { + + + + } + + + + } + + + + + Cancel + Save + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor.cs new file mode 100644 index 00000000..e676d6ed --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/PermissionDialog.razor.cs @@ -0,0 +1,120 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class PermissionDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public RoleInfo Role { get; set; } = default!; + + private List _groups = new(); + + protected override void OnInitialized() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + _groups = catalog + .GroupBy(p => p.Split('.')[0]) + .Select(g => new PermissionGroup + { + Name = g.Key, + Items = g.Select(p => new PermissionItem + { + Value = p, + Selected = selected.Contains(p) + }).ToList() + }) + .OrderBy(x => x.Name) + .ToList(); + } + + private void ToggleGroup(PermissionGroup group, bool value) + { + foreach (var item in group.Items) + item.Selected = value; + } + + private void TogglePermission(PermissionItem item, bool value) + { + item.Selected = value; + } + + private bool? GetGroupState(PermissionGroup group) + { + var selected = group.Items.Count(x => x.Selected); + + if (selected == 0) + return false; + + if (selected == group.Items.Count) + return true; + + return null; + } + + private async Task Save() + { + var permissions = _groups.SelectMany(g => g.Items).Where(x => x.Selected).Select(x => Permission.From(x.Value)).ToList(); + + var req = new SetRolePermissionsRequest + { + RoleId = Role.Id, + Permissions = permissions + }; + + var result = await UAuthClient.Authorization.SetRolePermissionsAsync(req); + + if (!result.IsSuccess) + { + Snackbar.Add(result.ErrorText ?? "Failed to update permissions", Severity.Error); + return; + } + + var result2 = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery() { Search = Role.Name }); + if (result2.Value?.Items is not null) + { + Role = result2.Value.Items.First(); + } + + Snackbar.Add("Permissions updated", Severity.Success); + RefreshUI(); + } + + private void RefreshUI() + { + var catalog = UAuthPermissionCatalog.GetAdminPermissions(); + var expanded = PermissionExpander.Expand(Role.Permissions, catalog); + var selected = expanded.Select(x => x.Value).ToHashSet(); + + foreach (var group in _groups) + { + foreach (var item in group.Items) + { + item.Selected = selected.Contains(item.Value); + } + } + + StateHasChanged(); + } + + private void Cancel() => MudDialog.Cancel(); + + private class PermissionGroup + { + public string Name { get; set; } = ""; + public List Items { get; set; } = new(); + } + + private class PermissionItem + { + public string Value { get; set; } = ""; + public bool Selected { get; set; } + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor new file mode 100644 index 00000000..a36af169 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor @@ -0,0 +1,103 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + Identifier Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + @if (_loaded) + { + + + + + + Name + + + + + + + + + + + + + + + + + + + Personal + + + + + + + + + + + + + + + + + + + Localization + + + + + + + + + + + @foreach (var tz in TimeZoneInfo.GetSystemTimeZones()) + { + @tz.Id - @tz.DisplayName + } + + + + + + + + + + } + else + { +
+ +
+ } +
+ + Cancel + Save + +
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 new file mode 100644 index 00000000..c0442702 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ProfileDialog.razor.cs @@ -0,0 +1,116 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class ProfileDialog +{ + private MudForm? _form; + private string? _firstName; + private string? _lastName; + private string? _displayName; + private DateTime? _birthDate; + private string? _gender; + private string? _bio; + private string? _language; + private string? _timeZone; + private string? _culture; + private bool _loaded; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Users.GetMeAsync(); + } + else + { + result = await UAuthClient.Users.GetUserAsync(UserKey.Value); + } + + if (result.IsSuccess && result.Value is not null) + { + var p = result.Value; + + _firstName = p.FirstName; + _lastName = p.LastName; + _displayName = p.DisplayName; + + _gender = p.Gender; + _birthDate = p.BirthDate?.ToDateTime(TimeOnly.MinValue); + _bio = p.Bio; + + _language = p.Language; + _timeZone = p.TimeZone; + _culture = p.Culture; + } + _loaded = true; + } + + private async Task SaveAsync() + { + if (AuthState is null || AuthState.Identity is null) + { + Snackbar.Add("No AuthState found.", Severity.Error); + return; + } + + if (_form is not null) + { + await _form.Validate(); + if (!_form.IsValid) + return; + } + + var request = new UpdateProfileRequest + { + FirstName = _firstName, + LastName = _lastName, + DisplayName = _displayName, + BirthDate = _birthDate.HasValue ? DateOnly.FromDateTime(_birthDate.Value) : null, + Gender = _gender, + Bio = _bio, + Language = _language, + TimeZone = _timeZone, + Culture = _culture + }; + + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Users.UpdateMeAsync(request); + } + else + { + result = await UAuthClient.Users.UpdateUserAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Profile updated", Severity.Success); + MudDialog.Close(DialogResult.Ok(true)); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to update profile", Severity.Error); + } + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor new file mode 100644 index 00000000..06a515aa --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor @@ -0,0 +1,38 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Credentials.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Reset Credential + + + + + + This is a demonstration of how to implement a credential reset flow. + In a production application, you should use reset token or code in email, SMS etc. verification steps. + + + Reset request always returns ok even with not found users due to security reasons. + + + Request Reset + @if (_resetRequested) + { + Your reset code is: (Copy it before next step) + @_resetCode + Use Reset Code + } + + + + + Close + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs new file mode 100644 index 00000000..977d1e38 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResetDialog.razor.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class ResetDialog +{ + private bool _resetRequested = false; + private string? _resetCode; + private string? _identifier; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private async Task RequestResetAsync() + { + var request = new BeginResetCredentialRequest + { + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code, + Identifier = _identifier ?? string.Empty + }; + + var result = await UAuthClient.Credentials.BeginResetMyAsync(request); + if (!result.IsSuccess || result.Value is null) + { + Snackbar.Add(result.ErrorText ?? "Failed to request credential reset.", Severity.Error); + return; + } + + _resetCode = result.Value.Token; + _resetRequested = true; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor new file mode 100644 index 00000000..fe0ebb2a --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor @@ -0,0 +1,54 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi +@inject ProductApiService Api +@inject ISnackbar Snackbar + + + + + Resource Api + Sample demonstration of a resource. + + + + Reload + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Add + + + + + + + \ No newline at end of file diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs new file mode 100644 index 00000000..33ddb1cc --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/ResourceApiDialog.razor.cs @@ -0,0 +1,95 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class ResourceApiDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + private List _products = new List(); + private string? _newName = null; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _products = (await Api.GetAllAsync()).Value ?? new(); + StateHasChanged(); + } + } + + private async Task CommittedItemChanges(SampleProduct item) + { + var result = await Api.UpdateAsync(item.Id, item); + + if (result.IsSuccess) + { + Snackbar.Add("Product updated successfully", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to update product.", Severity.Error); + } + + return DataGridEditFormAction.Close; + } + + private async Task GetProducts() + { + var result = await Api.GetAllAsync(); + + if (result.IsSuccess) + { + _products = result.Value ?? new(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error); + } + } + + private async Task CreateProduct() + { + var product = new SampleProduct + { + Name = _newName + }; + + var result = await Api.CreateAsync(product); + + if (result.IsSuccess) + { + Snackbar.Add("New product created."); + _products = (await Api.GetAllAsync()).Value ?? new(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error); + } + } + + private async Task DeleteProduct(int id) + { + var result = await Api.DeleteAsync(id); + + if (result.IsSuccess) + { + Snackbar.Add("Product deleted succesfully.", Severity.Success); + _products = (await Api.GetAllAsync()).Value ?? new(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Process failed.", Severity.Error); + } + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor new file mode 100644 index 00000000..bfcf9428 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor @@ -0,0 +1,90 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@using CodeBeam.UltimateAuth.Core.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService + + + + + Role Management + Manage system roles + + + + @if (_loaded) + { + + + + + Roles + + + + + + + + + + + @GetPermissionCount(context.Item) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Create + + + + + } + else + { +
+ +
+ } +
+ + + Close + +
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor.cs new file mode 100644 index 00000000..22c6ea7d --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/RoleDialog.razor.cs @@ -0,0 +1,176 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class RoleDialog +{ + private MudDataGrid? _grid; + private bool _loading; + private string? _newRoleName; + private bool _loaded; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + _loaded = true; + StateHasChanged(); + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new RoleQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Authorization.QueryRolesAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.ErrorText ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task CommittedItemChanges(RoleInfo role) + { + var req = new RenameRoleRequest + { + Id = role.Id, + Name = role.Name + }; + + var result = await UAuthClient.Authorization.RenameRoleAsync(req); + + if (result.IsSuccess) + { + Snackbar.Add("Role renamed", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Rename failed", Severity.Error); + } + + await ReloadAsync(); + return DataGridEditFormAction.Close; + } + + private async Task CreateRole() + { + if (string.IsNullOrWhiteSpace(_newRoleName)) + { + Snackbar.Add("Role name required.", Severity.Warning); + return; + } + + var req = new CreateRoleRequest + { + Name = _newRoleName + }; + + var res = await UAuthClient.Authorization.CreateRoleAsync(req); + + if (res.IsSuccess) + { + Snackbar.Add("Role created.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(res.ErrorText ?? "Creation failed.", Severity.Error); + } + } + + private async Task DeleteRole(RoleId roleId) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Delete role", + "Are you sure?", + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + return; + + var req = new DeleteRoleRequest() { Id = roleId }; + var result = await UAuthClient.Authorization.DeleteRoleAsync(req); + + if (result.IsSuccess) + { + Snackbar.Add($"Role deleted, assignments removed from {result.Value?.RemovedAssignments.ToString() ?? "unknown"} users.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Deletion failed.", Severity.Error); + } + } + + private async Task EditPermissions(RoleInfo role) + { + var dialog = await DialogService.ShowAsync( + "Edit Permissions", + new DialogParameters + { + { nameof(PermissionDialog.Role), role } + }, + new DialogOptions + { + CloseButton = true, + MaxWidth = MaxWidth.Large, + FullWidth = true + }); + + var result = await dialog.Result; + await ReloadAsync(); + } + + private async Task ReloadAsync() + { + _loading = true; + await Task.Delay(300); + if (_grid is null) + return; + + await _grid.ReloadServerData(); + _loading = false; + } + + private int GetPermissionCount(RoleInfo role) + { + var expanded = PermissionExpander.Expand(role.Permissions, UAuthPermissionCatalog.GetAdminPermissions()); + return expanded.Count; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor new file mode 100644 index 00000000..e5ee0c4d --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor @@ -0,0 +1,226 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@inject IUAuthStateManager StateManager +@inject NavigationManager Nav + + + + Session Management + + @if (UserKey is null) + { + User: @AuthState?.Identity?.DisplayName + } + else + { + UserKey: @UserKey.Value + } + + + + @if (_loaded) + { + @if (_chainDetail is not null) + { + + + + Device Details + + + + @if (!_chainDetail.IsRevoked) + { + + Revoke Device + + } + + + + + + + Device Type + @_chainDetail.DeviceType + + + + Platform + @_chainDetail.Platform + + + + Operating System + @_chainDetail.OperatingSystem + + + + Browser + @_chainDetail.Browser + + + + Created + @_chainDetail.CreatedAt.ToLocalTime() + + + + Last Seen + @_chainDetail.LastSeenAt.ToLocalTime() + + + + State + + @_chainDetail.State + + + + + Active Session + @_chainDetail.ActiveSessionId + + + + Rotation Count + @_chainDetail.RotationCount + + + + Touch Count + @_chainDetail.TouchCount + + + + + + Session History + + + + Session Id + Created + Expires + Status + + + + @context.SessionId + @context.CreatedAt.ToLocalTime() + @context.ExpiresAt.ToLocalTime() + + @if (context.IsRevoked) + { + Revoked + } + else + { + Active + } + + + + + } + else + { + + Logout All Devices + @if (UserKey == null) + { + Logout Other Devices + } + Revoke All Devices + @if (UserKey == null) + { + Revoke Other Devices + } + + + + Sessions + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.ChainId + + + + Created At + @context.Item.CreatedAt + + + + Touch Count + @context.Item.TouchCount + + + + Rotation Count + @context.Item.RotationCount + + + + + + + + + } + } + else + { +
+ +
+ } +
+ + Cancel + +
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor.cs new file mode 100644 index 00000000..1aaceacb --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/SessionDialog.razor.cs @@ -0,0 +1,286 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class SessionDialog +{ + private MudDataGrid? _grid; + private bool _loading = false; + private bool _reloadQueued; + private SessionChainDetail? _chainDetail; + private bool _loaded; + + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey? UserKey { get; set; } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + _loaded = true; + var result = await UAuthClient.Sessions.GetMyChainsAsync(); + if (result != null && result.IsSuccess && result.Value != null) + { + await ReloadAsync(); + } + StateHasChanged(); + } + } + + private async Task> LoadServerData(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new PageRequest + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + UAuthResult> res; + + if (UserKey is null) + { + res = await UAuthClient.Sessions.GetMyChainsAsync(req); + } + else + { + res = await UAuthClient.Sessions.GetUserChainsAsync(UserKey.Value, req); + } + + if (!res.IsSuccess || res.Value is null) + { + Snackbar.Add(res.Problem?.Title ?? "Failed", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + await Task.Delay(300); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task LogoutAllAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Flows.LogoutAllMyDevicesAsync(); + } + else + { + result = await UAuthClient.Flows.LogoutAllUserDevicesAsync(UserKey.Value); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + if (UserKey is null) + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task LogoutOthersAsync() + { + var result = await UAuthClient.Flows.LogoutMyOtherDevicesAsync(); + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of other devices.", Severity.Success); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout", Severity.Error); + } + } + + private async Task LogoutDeviceAsync(SessionChainId chainId) + { + LogoutDeviceRequest request = new() { ChainId = chainId }; + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Flows.LogoutMyDeviceAsync(request); + } + else + { + result = await UAuthClient.Flows.LogoutUserDeviceAsync(UserKey.Value, request); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of device.", Severity.Success); + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeAllAsync() + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.RevokeAllMyChainsAsync(); + } + else + { + result = await UAuthClient.Sessions.RevokeAllUserChainsAsync(UserKey.Value); + } + + if (result.IsSuccess) + { + Snackbar.Add("Logged out of all devices.", Severity.Success); + + if (UserKey is null) + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeOthersAsync() + { + var result = await UAuthClient.Sessions.RevokeMyOtherChainsAsync(); + if (result.IsSuccess) + { + Snackbar.Add("Revoked all other devices.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task RevokeChainAsync(SessionChainId chainId) + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.RevokeMyChainAsync(chainId); + } + else + { + result = await UAuthClient.Sessions.RevokeUserChainAsync(UserKey.Value, chainId); + } + + if (result.IsSuccess) + { + Snackbar.Add("Device revoked successfully.", Severity.Success); + + if (result?.Value?.CurrentChain == true) + { + Nav.NavigateTo("/login"); + return; + } + await ReloadAsync(); + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to logout.", Severity.Error); + } + } + + private async Task ShowChainDetailsAsync(SessionChainId chainId) + { + UAuthResult result; + + if (UserKey is null) + { + result = await UAuthClient.Sessions.GetMyChainDetailAsync(chainId); + } + else + { + result = await UAuthClient.Sessions.GetUserChainDetailAsync(UserKey.Value, chainId); + } + + if (result.IsSuccess) + { + _chainDetail = result.Value; + } + else + { + Snackbar.Add(result?.ErrorText ?? "Failed to fetch chain details.", Severity.Error); + _chainDetail = null; + } + } + + private void ClearDetail() + { + _chainDetail = null; + } + + private void Cancel() => MudDialog.Cancel(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor new file mode 100644 index 00000000..b8f511f8 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor @@ -0,0 +1,75 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + @_user?.UserKey.Value + + + + + + + + Display Name + @_user?.DisplayName + + + + Username + @_user?.UserName + + + + Email + @_user?.PrimaryEmail + + + + Phone + @_user?.PrimaryPhone + + + + Created + @_user?.CreatedAt?.ToLocalTime() + + + + Status + @_user?.Status + + + @foreach (var s in Enum.GetValues()) + { + @s + } + + Change + + + + + + + + Management + + Sessions + Profile + Identifiers + Credentials + Roles + + + + + + Close + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor.cs new file mode 100644 index 00000000..5d3e3455 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserDetailDialog.razor.cs @@ -0,0 +1,100 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Common; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class UserDetailDialog +{ + private UserView? _user; + private AdminAssignableUserStatus _status; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + var result = await UAuthClient.Users.GetUserAsync(UserKey); + + if (result.IsSuccess) + { + _user = result.Value; + _status = _user?.Status.ToAdminAssignableUserStatus() ?? AdminAssignableUserStatus.Unknown; + } + } + + private async Task OpenSessions() + { + await DialogService.ShowAsync("Session Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenProfile() + { + await DialogService.ShowAsync("Profile Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenIdentifiers() + { + await DialogService.ShowAsync("Identifier Management", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenCredentials() + { + await DialogService.ShowAsync("Credentials", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenRoles() + { + await DialogService.ShowAsync("Roles", UAuthDialog.GetDialogParameters(AuthState, _user?.UserKey), UAuthDialog.GetDialogOptions()); + } + + private async Task ChangeStatusAsync() + { + if (_user is null) + return; + + ChangeUserStatusAdminRequest request = new() + { + NewStatus = _status, + }; + + var result = await UAuthClient.Users.ChangeUserStatusAsync(_user.UserKey, request); + + if (result.IsSuccess) + { + Snackbar.Add("User status updated", Severity.Success); + _user = _user with { Status = _status.ToUserStatus() }; + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + } + + private Color GetStatusColor(UserStatus? status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor new file mode 100644 index 00000000..6e754848 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor @@ -0,0 +1,49 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Authorization.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + + User Roles + UserKey: @UserKey.Value + + + + + Assigned Roles + + @if (_roles.Count == 0) + { + No roles assigned + } + + + @foreach (var role in _roles) + { + @role + } + + + + + Add Role + + + + @foreach (var role in _allRoles) + { + @role.Name + } + + + Add + + + + + + Close + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor.cs new file mode 100644 index 00000000..e89d60ff --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UserRoleDialog.razor.cs @@ -0,0 +1,124 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class UserRoleDialog +{ + [CascadingParameter] + private IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + [Parameter] + public UserKey UserKey { get; set; } = default!; + + private List _roles = new(); + private List _allRoles = new(); + + private string? _selectedRole; + + protected override async Task OnInitializedAsync() + { + await LoadRoles(); + } + + private async Task LoadRoles() + { + var userRoles = await UAuthClient.Authorization.GetUserRolesAsync(UserKey); + + if (userRoles.IsSuccess && userRoles.Value != null) + _roles = userRoles.Value.Roles.Items.Select(x => x.Name).ToList(); + + var roles = await UAuthClient.Authorization.QueryRolesAsync(new RoleQuery + { + PageNumber = 1, + PageSize = 200 + }); + + if (roles.IsSuccess && roles.Value != null) + _allRoles = roles.Value.Items.ToList(); + } + + private async Task AddRole() + { + if (string.IsNullOrWhiteSpace(_selectedRole)) + return; + + var request = new AssignRoleRequest + { + UserKey = UserKey, + RoleName = _selectedRole + }; + + var result = await UAuthClient.Authorization.AssignRoleToUserAsync(request); + + if (result.IsSuccess) + { + _roles.Add(_selectedRole); + Snackbar.Add("Role assigned", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + + _selectedRole = null; + } + + private async Task RemoveRole(string role) + { + var confirm = await DialogService.ShowMessageBoxAsync( + "Remove Role", + $"Remove {role} from user?", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + + if (role == "Admin") + { + var confirm2 = await DialogService.ShowMessageBoxAsync( + "Are You Sure", + "You are going to remove admin role. This action may cause the application unuseable.", + yesText: "Remove", + noText: "Cancel", + options: new DialogOptions() { MaxWidth = MaxWidth.Medium, FullWidth = true, BackgroundClass = "uauth-blur-slight" }); + + if (confirm2 != true) + { + Snackbar.Add("Role remove process cancelled.", Severity.Info); + return; + } + } + + var request = new RemoveRoleRequest + { + UserKey = UserKey, + RoleName = role + }; + + var result = await UAuthClient.Authorization.RemoveRoleFromUserAsync(request); + + if (result.IsSuccess) + { + _roles.Remove(role); + Snackbar.Add("Role removed.", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed", Severity.Error); + } + } + + private void Close() => MudDialog.Close(); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor new file mode 100644 index 00000000..f9df9a84 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor @@ -0,0 +1,94 @@ +๏ปฟ@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Common +@using CodeBeam.UltimateAuth.Users.Contracts +@inject IUAuthClient UAuthClient +@inject IDialogService DialogService +@inject ISnackbar Snackbar + + + + User Management + Browse, create and manage users + + + + @if (_loaded) + { + + + + + + + + + + + + + + Users + + New User + + + + + + + + + + + @context.Item.Status + + + + + + + + + + + + + + + + + + + + + Id + @context.Item.UserKey.Value + + + + Created At + @context.Item.CreatedAt + + + + + + + + + + } + else + { +
+ +
+ } +
+ + + Close + +
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor.cs new file mode 100644 index 00000000..a215a013 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Components/Dialogs/UsersDialog.razor.cs @@ -0,0 +1,188 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Common; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; + +public partial class UsersDialog +{ + private MudDataGrid? _grid; + private bool _loading; + private string? _search; + private bool _reloadQueued; + private UserStatus? _statusFilter; + private bool _loaded; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] + public UAuthState AuthState { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (firstRender) + { + _loaded = true; + StateHasChanged(); + } + } + + private async Task> LoadUsers(GridState state, CancellationToken ct) + { + var sort = state.SortDefinitions?.FirstOrDefault(); + + var req = new UserQuery + { + PageNumber = state.Page + 1, + PageSize = state.PageSize, + Search = _search, + Status = _statusFilter, + SortBy = sort?.SortBy, + Descending = sort?.Descending ?? false + }; + + var res = await UAuthClient.Users.QueryAsync(req); + + if (!res.IsSuccess || res.Value == null) + { + Snackbar.Add(res.ErrorText ?? "Failed to load users.", Severity.Error); + + return new GridData + { + Items = Array.Empty(), + TotalItems = 0 + }; + } + + return new GridData + { + Items = res.Value.Items, + TotalItems = res.Value.TotalCount + }; + } + + private async Task ReloadAsync() + { + if (_loading) + { + _reloadQueued = true; + return; + } + + if (_grid is null) + return; + + _loading = true; + await InvokeAsync(StateHasChanged); + + try + { + await _grid.ReloadServerData(); + } + finally + { + _loading = false; + + if (_reloadQueued) + { + _reloadQueued = false; + await ReloadAsync(); + } + + await InvokeAsync(StateHasChanged); + } + } + + private async Task OnStatusChanged(UserStatus? status) + { + _statusFilter = status; + await ReloadAsync(); + } + + private async Task OpenUser(UserKey userKey) + { + var dialog = await DialogService.ShowAsync("User", UAuthDialog.GetDialogParameters(AuthState, userKey), UAuthDialog.GetDialogOptions()); + await dialog.Result; + await ReloadAsync(); + } + + private async Task OpenCreateUser() + { + var dialog = await DialogService.ShowAsync( + "Create User", + new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true, + CloseButton = true + }); + + var result = await dialog.Result; + + if (result?.Canceled == false) + await ReloadAsync(); + } + + private async Task DeleteUserAsync(UserSummary user) + { + var confirm = await DialogService.ShowMessageBoxAsync( + title: "Delete user", + markupMessage: (MarkupString)$""" + Are you sure you want to delete {user.DisplayName ?? user.UserName ?? user.PrimaryEmail ?? user.UserKey}? +

+ This operation is intended for admin usage. + """, + yesText: "Delete", + cancelText: "Cancel", + options: new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + BackgroundClass = "uauth-blur-slight" + }); + + if (confirm != true) + return; + + var req = new DeleteUserRequest + { + Mode = DeleteMode.Soft + }; + + var result = await UAuthClient.Users.DeleteUserAsync(UserKey.Parse(user.UserKey, null), req); + + if (result.IsSuccess) + { + Snackbar.Add("User deleted successfully.", Severity.Success); + await ReloadAsync(); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to delete user.", Severity.Error); + } + } + + private static Color GetStatusColor(UserStatus status) + { + return status switch + { + UserStatus.Active => Color.Success, + UserStatus.SelfSuspended => Color.Warning, + UserStatus.Suspended => Color.Warning, + UserStatus.Disabled => Color.Error, + _ => Color.Default + }; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Infrastructure/DarkModeManager.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Infrastructure/DarkModeManager.cs new file mode 100644 index 00000000..de933317 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Infrastructure/DarkModeManager.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure; + +public sealed class DarkModeManager +{ + private const string StorageKey = "uauth:theme:dark"; + + private readonly IClientStorage _storage; + + public DarkModeManager(IClientStorage storage) + { + _storage = storage; + } + + public async Task InitializeAsync() + { + var value = await _storage.GetAsync(StorageScope.Local, StorageKey); + + if (bool.TryParse(value, out var parsed)) + IsDarkMode = parsed; + } + + public bool IsDarkMode { get; set; } + + public event Action? Changed; + + public async Task ToggleAsync() + { + IsDarkMode = !IsDarkMode; + + await _storage.SetAsync(StorageScope.Local, StorageKey, IsDarkMode.ToString()); + Changed?.Invoke(); + } + + public void Set(bool value) + { + if (IsDarkMode == value) + return; + + IsDarkMode = value; + Changed?.Invoke(); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor new file mode 100644 index 00000000..2788afc2 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor @@ -0,0 +1,65 @@ +๏ปฟ@inherits LayoutComponentBase +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject NavigationManager Nav + + + + + UltimateAuth + + Blazor WASM Sample + + + + + + + + + +
+ + + @((state.Identity?.DisplayName ?? "?").Trim() is var n ? (n.Length >= 2 ? n[..2] : n[..1]) : "?") + + +
+
+ + + @state.Identity?.DisplayName + @string.Join(", ", state.Claims.Roles) + + + + + + + + @if (state.Identity?.SessionState is not null && state.Identity.SessionState != SessionState.Active) + { + + + } + +
+
+ + + + +
+
+ + + @Body + +
+ + +
+ An unhandled error has occurred. + Reload + ๐Ÿ—™ +
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs new file mode 100644 index 00000000..8567fb06 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Layout/MainLayout.razor.cs @@ -0,0 +1,130 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure; +using Microsoft.AspNetCore.Components; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Layout; + +public partial class MainLayout +{ + [CascadingParameter] + public UAuthState UAuth { get; set; } = default!; + + [CascadingParameter] + public DarkModeManager DarkModeManager { get; set; } = default!; + + private async Task Refresh() + { + await UAuthClient.Flows.RefreshAsync(); + } + + private async Task Logout() + { + await UAuthClient.Flows.LogoutAsync(); + } + + private Color GetBadgeColor() + { + if (UAuth is null || !UAuth.IsAuthenticated) + return Color.Error; + + if (UAuth.IsStale) + return Color.Warning; + + var state = UAuth.Identity?.SessionState; + + if (state is null || state == SessionState.Active) + return Color.Success; + + if (state == SessionState.Invalid) + return Color.Error; + + return Color.Warning; + } + + private void HandleSignInClick() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + Nav.NavigateTo("/login?focus=1", replace: true, forceLoad: true); + return; + } + + GoToLoginWithReturn(); + } + + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active โ€ข Tenant: {result.Snapshot?.Identity?.Tenant.Value} โ€ข User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + + private void GoToLoginWithReturn() + { + var uri = Nav.ToAbsoluteUri(Nav.Uri); + + if (uri.AbsolutePath.EndsWith("/login", StringComparison.OrdinalIgnoreCase)) + { + Nav.NavigateTo("/login", replace: true); + return; + } + + var current = Nav.ToBaseRelativePath(uri.ToString()); + if (string.IsNullOrWhiteSpace(current)) + current = "home"; + + var returnUrl = Uri.EscapeDataString("/" + current.TrimStart('/')); + Nav.NavigateTo($"/login?returnUrl={returnUrl}", replace: true); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AnonymousTestPage.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AnonymousTestPage.razor new file mode 100644 index 00000000..10d035ba --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AnonymousTestPage.razor @@ -0,0 +1 @@ +๏ปฟ@page "/anonymous-test" diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor new file mode 100644 index 00000000..e5554c4e --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/AuthorizedTestPage.razor @@ -0,0 +1,26 @@ +๏ปฟ@page "/authorized-test" +@attribute [Authorize] + + + + + + + Everything is Ok + + + If you see this section, it means you succesfully logged in. + + + + Go Profile + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + \ No newline at end of file diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor new file mode 100644 index 00000000..beac4f94 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor @@ -0,0 +1,453 @@ +๏ปฟ@page "/home" +@attribute [Authorize] +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject UAuthClientDiagnostics Diagnostics +@inject AuthenticationStateProvider AuthStateProvider +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using System.Security.Claims +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Defaults +@using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Custom +@using Microsoft.AspNetCore.Authorization + +@if (AuthState?.Identity?.UserStatus == UserStatus.SelfSuspended) +{ + + + + Your account is suspended. Please active it before continue. + + + + Set Active + Logout + + + + return; +} + +@if (AuthState?.Identity?.UserStatus == UserStatus.Suspended) +{ + + + + Your account is suspended. Please contact with administrator. + + + + Logout + + + + return; +} + + + + + + + + + + + Session + + + + + + + Validate + + + + + + Manual Refresh + + + + + + Logout + + + + + + Account + + + + + Manage Sessions + + + + Manage Profile + + + + Manage Identifiers + + + + Manage Credentials + + + + Suspend | Delete Account + + + + Admin + + + + + + + + + @if (_showAdminPreview) + { + + Admin operations are shown for preview. Sign in as an Admin to execute them. + + } + + @if (AuthState?.IsInRole("Admin") == true || _showAdminPreview) + { + + + + @* *@ + @* *@ + User Management + @* *@ + + + + + + @* *@ + Role Management + @* *@ + + + + } + + + Resource Api + + + + + Manage Resource + + + + + + + + + + + + @((AuthState?.Identity?.DisplayName ?? "?").Substring(0, Math.Min(2, (AuthState?.Identity?.DisplayName ?? "?").Length))) + + + + @AuthState?.Identity?.DisplayName + + @foreach (var role in AuthState?.Claims?.Roles ?? Enumerable.Empty()) + { + + @role + + } + + + + + + + + + + @if (_selectedAuthState == "UAuthState") + { + + +
+ + + Tenant + + @AuthState?.Identity?.Tenant.Value +
+ +
+ + +
+ + + User Id + + @AuthState?.Identity?.UserKey.Value +
+
+ + +
+ + + Authenticated + + @(AuthState?.IsAuthenticated == true ? "Yes" : "No") +
+
+ + +
+ + + Session State + + @AuthState?.Identity?.SessionState?.ToDescriptionString() +
+
+ + +
+ + + Username + + @AuthState?.Identity?.PrimaryUserName +
+
+ + +
+ + + Display Name + + @AuthState?.Identity?.DisplayName +
+
+ + + + + + + Email + + @AuthState?.Identity?.PrimaryEmail + + + + + + Phone + + @AuthState?.Identity?.PrimaryPhone + + + + + + + + Authenticated At + + @* TODO: Add IUAuthDateTimeFormatter *@ + @FormatLocalTime(AuthState?.Identity?.AuthenticatedAt) + + + + + + Last Validated At + + @* TODO: Validation call should update last validated at *@ + @FormatLocalTime(AuthState?.LastValidatedAt) + +
+ } + else if (_selectedAuthState == "AspNetCoreState") + { + + +
+ + + Authenticated + + @(_aspNetCoreState?.Identity?.IsAuthenticated == true ? "Yes" : "No") +
+
+ + +
+ + + User Id + + @_aspNetCoreState?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value +
+
+ + +
+ + + Username + + @_aspNetCoreState?.Identity?.Name +
+
+ + +
+ + + Authentication Type + + @_aspNetCoreState?.Identity?.AuthenticationType +
+
+
+ } +
+
+
+ + + + + + @GetHealthText() + + + Lifecycle + + + + + + Started + @Diagnostics.StartCount + + @if (Diagnostics.StartedAt is not null) + { + + + + @FormatRelative(Diagnostics.StartedAt) + + + } + + + + + Stopped + @Diagnostics.StopCount + + + + + + Terminated + @Diagnostics.TerminatedCount + + @if (Diagnostics.TerminatedAt is not null) + { + + + + + @FormatRelative(Diagnostics.TerminatedAt) + + + + } + + + + + + Refresh Metrics + + + + + + + Total Attempts + @Diagnostics.RefreshAttemptCount + + + + + + + Success + + @Diagnostics.RefreshSuccessCount + + + + + + Automatic + @Diagnostics.AutomaticRefreshCount + + + + + + Manual + @Diagnostics.ManualRefreshCount + + + + + + Touched/Rotated + @Diagnostics.RefreshTouchedCount / @Diagnostics.RefreshRotatedCount + + + + + + No-Op + @Diagnostics.RefreshNoOpCount + + + + + + Reauth Required + @Diagnostics.RefreshReauthRequiredCount + + + + + + + +
+
+
diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs new file mode 100644 index 00000000..6c8122d8 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Home.razor.cs @@ -0,0 +1,227 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Common; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Components.Authorization; +using MudBlazor; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class Home : UAuthFlowPageBase +{ + private string _selectedAuthState = "UAuthState"; + private ClaimsPrincipal? _aspNetCoreState; + + private bool _showAdminPreview = false; + + protected override async Task OnInitializedAsync() + { + var initial = await AuthStateProvider.GetAuthenticationStateAsync(); + _aspNetCoreState = initial.User; + AuthStateProvider.AuthenticationStateChanged += OnAuthStateChanged; + Diagnostics.Changed += OnDiagnosticsChanged; + } + + private void OnAuthStateChanged(Task task) + { + _ = HandleAuthStateChangedAsync(task); + } + + private async Task HandleAuthStateChangedAsync(Task task) + { + try + { + var state = await task; + _aspNetCoreState = state.User; + await InvokeAsync(StateHasChanged); + } + catch + { + + } + } + + private void OnDiagnosticsChanged() + { + InvokeAsync(StateHasChanged); + } + + private async Task Logout() => await UAuthClient.Flows.LogoutAsync(); + + private async Task RefreshSession() => await UAuthClient.Flows.RefreshAsync(false); + + private async Task Validate() + { + try + { + var result = await UAuthClient.Flows.ValidateAsync(); + + if (result.IsValid) + { + if (result.Snapshot?.Identity.UserStatus == UserStatus.SelfSuspended) + { + Snackbar.Add("Your account is suspended by you.", Severity.Warning); + return; + } + Snackbar.Add($"Session active โ€ข Tenant: {result.Snapshot?.Identity?.Tenant.Value} โ€ข User: {result.Snapshot?.Identity?.PrimaryUserName}", Severity.Success); + } + else + { + switch (result.State) + { + case SessionState.Expired: + Snackbar.Add("Session expired. Please sign in again.", Severity.Warning); + break; + + case SessionState.DeviceMismatch: + Snackbar.Add("Session invalid for this device.", Severity.Error); + break; + + default: + Snackbar.Add($"Session state: {result.State}", Severity.Error); + break; + } + } + } + catch (UAuthTransportException) + { + Snackbar.Add("Network error.", Severity.Error); + } + catch (UAuthProtocolException) + { + Snackbar.Add("Invalid response.", Severity.Error); + } + catch (UAuthException ex) + { + Snackbar.Add($"UAuth error: {ex.Message}", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Unexpected error: {ex.Message}", Severity.Error); + } + } + + private Color GetHealthColor() + { + if (Diagnostics.RefreshReauthRequiredCount > 0) + return Color.Warning; + + if (Diagnostics.TerminatedCount > 0) + return Color.Error; + + return Color.Success; + } + + private string GetHealthText() + { + if (Diagnostics.RefreshReauthRequiredCount > 0) + return "Reauthentication Required"; + + if (Diagnostics.TerminatedCount > 0) + return "Session Terminated"; + + return "Healthy"; + } + + private string? FormatRelative(DateTimeOffset? utc) + { + if (utc is null) + return null; + + var diff = DateTimeOffset.UtcNow - utc.Value; + + if (diff.TotalSeconds < 5) + return "just now"; + + if (diff.TotalSeconds < 60) + return $"{(int)diff.Seconds} secs ago"; + + if (diff.TotalMinutes < 60) + return $"{(int)diff.TotalMinutes} min ago"; + + if (diff.TotalHours < 24) + return $"{(int)diff.TotalHours} hrs ago"; + + return utc.Value.ToLocalTime().ToString("dd MMM yyyy"); + } + + private string? FormatLocalTime(DateTimeOffset? utc) + { + return utc?.ToLocalTime().ToString("dd MMM yyyy โ€ข HH:mm:ss"); + } + + private async Task OpenProfileDialog() + { + await DialogService.ShowAsync("Manage Profile", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenIdentifierDialog() + { + await DialogService.ShowAsync("Manage Identifiers", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenSessionDialog() + { + await DialogService.ShowAsync("Manage Sessions", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenCredentialDialog() + { + await DialogService.ShowAsync("Session Diagnostics", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenAccountStatusDialog() + { + await DialogService.ShowAsync("Manage Account", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.ExtraSmall)); + } + + private async Task OpenUserDialog() + { + await DialogService.ShowAsync("User Management", GetDialogParameters(), UAuthDialog.GetDialogOptions(MaxWidth.Large)); + } + + private async Task OpenRoleDialog() + { + await DialogService.ShowAsync("Role Management", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private async Task OpenResourceApiDialog() + { + await DialogService.ShowAsync("Resource Api", GetDialogParameters(), UAuthDialog.GetDialogOptions()); + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + + private async Task SetAccountActiveAsync() + { + ChangeUserStatusSelfRequest request = new() { NewStatus = SelfAssignableUserStatus.Active }; + var result = await UAuthClient.Users.ChangeMyStatusAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Account activated successfully.", Severity.Success); + } + else + { + Snackbar.Add(result?.Problem?.Detail ?? result?.Problem?.Title ?? "Activation failed.", Severity.Error); + } + } + + public override void Dispose() + { + base.Dispose(); + AuthStateProvider.AuthenticationStateChanged -= OnAuthStateChanged; + Diagnostics.Changed -= OnDiagnosticsChanged; + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor new file mode 100644 index 00000000..1e4a9016 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor @@ -0,0 +1,4 @@ +๏ปฟ@page "/" + +@inject NavigationManager Nav +@inject AuthenticationStateProvider AuthProvider diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor.cs new file mode 100644 index 00000000..e5844e6d --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/LandingPage.razor.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class LandingPage +{ + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + var state = await AuthProvider.GetAuthenticationStateAsync(); + var isAuthenticated = state.User.Identity?.IsAuthenticated == true; + + Nav.NavigateTo(isAuthenticated ? "/home" : $"{UAuthConstants.Routes.LoginRedirect}?fresh=true"); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor new file mode 100644 index 00000000..5c3245d9 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor @@ -0,0 +1,134 @@ +๏ปฟ@page "/login" +@attribute [UAuthLoginPage] +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + 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 new file mode 100644 index 00000000..b644ee9a --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Login.razor.cs @@ -0,0 +1,216 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Components.Dialogs; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class Login : UAuthFlowPageBase +{ + private string? _username; + private string? _password; + private UAuthClientProductInfo? _productInfo; + private MudTextField _usernameField = default!; + + private CancellationTokenSource? _lockoutCts; + private PeriodicTimer? _lockoutTimer; + private DateTimeOffset? _lockoutUntil; + private TimeSpan _remaining; + private bool _isLocked; + private DateTimeOffset? _lockoutStartedAt; + private TimeSpan _lockoutDuration; + private double _progressPercent; + private int? _remainingAttempts = null; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + protected override Task OnUAuthPayloadAsync(AuthFlowPayload payload) + { + HandleLoginPayload(payload); + return Task.CompletedTask; + } + + protected override async Task OnFocusRequestedAsync() + { + await _usernameField.FocusAsync(); + } + + private void HandleLoginPayload(AuthFlowPayload payload) + { + if (payload.Flow != AuthFlowType.Login) + return; + + if (payload.Reason == AuthFailureReason.LockedOut && payload.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = payload.RemainingAttempts; + + ShowLoginError(payload.Reason, payload.RemainingAttempts); + } + + private void ShowLoginError(AuthFailureReason? reason, int? remainingAttempts) + { + string message = reason switch + { + AuthFailureReason.InvalidCredentials when remainingAttempts is > 0 + => $"Invalid username or password. {remainingAttempts} attempt(s) remaining.", + + AuthFailureReason.InvalidCredentials + => "Invalid username or password.", + + AuthFailureReason.RequiresMfa + => "Multi-factor authentication required.", + + AuthFailureReason.LockedOut + => "Your account is locked.", + + _ => "Login failed." + }; + + Snackbar.Add(message, Severity.Error); + } + + private async Task StartPkceLogin() + { + string? returnUrl = null; + if (!string.IsNullOrEmpty(ReturnUrl)) + returnUrl = Nav.BaseUri + ReturnUrl.TrimStart('/'); + + await UAuthClient.Flows.BeginPkceAsync(returnUrl); + } + + private async Task ProgrammaticLogin() + { + var deviceId = await DeviceIdProvider.GetOrCreateAsync(); + var request = new LoginRequest + { + Identifier = "admin", + Secret = "admin", + }; + await UAuthClient.Flows.LoginAsync(request, ReturnUrl ?? "/home"); + } + + private async Task HandleLoginResult(IUAuthTryResult result) + { + if (!result.Success) + { + if (result.Reason == AuthFailureReason.LockedOut && result.LockoutUntilUtc is { } until) + { + _lockoutUntil = until; + StartCountdown(); + } + + _remainingAttempts = result.RemainingAttempts; + ShowLoginError(result.Reason, result.RemainingAttempts); + } + } + + private async void StartCountdown() + { + if (_lockoutUntil is null) + return; + + _isLocked = true; + _lockoutStartedAt = DateTimeOffset.UtcNow; + _lockoutDuration = _lockoutUntil.Value - DateTimeOffset.UtcNow; + UpdateRemaining(); + + _lockoutCts?.Cancel(); + _lockoutCts = new CancellationTokenSource(); + + _lockoutTimer?.Dispose(); + _lockoutTimer = new PeriodicTimer(TimeSpan.FromSeconds(1)); + + try + { + while (await _lockoutTimer.WaitForNextTickAsync(_lockoutCts.Token)) + { + UpdateRemaining(); + + if (_remaining <= TimeSpan.Zero) + { + ResetLockoutState(); + await InvokeAsync(StateHasChanged); + break; + } + + await InvokeAsync(StateHasChanged); + } + } + catch (OperationCanceledException) + { + + } + } + + private void ResetLockoutState() + { + _isLocked = false; + _lockoutUntil = null; + _progressPercent = 0; + _remainingAttempts = null; + } + + private void UpdateRemaining() + { + if (_lockoutUntil is null || _lockoutStartedAt is null) + return; + + var now = DateTimeOffset.UtcNow; + + _remaining = _lockoutUntil.Value - now; + + if (_remaining <= TimeSpan.Zero) + { + _remaining = TimeSpan.Zero; + return; + } + + var elapsed = now - _lockoutStartedAt.Value; + + if (_lockoutDuration.TotalSeconds > 0) + { + var percent = 100 - (elapsed.TotalSeconds / _lockoutDuration.TotalSeconds * 100); + _progressPercent = Math.Max(0, percent); + } + } + + private async Task OpenResetDialog() + { + await DialogService.ShowAsync("Reset Credentials", GetDialogParameters(), GetDialogOptions()); + } + + private DialogOptions GetDialogOptions() + { + return new DialogOptions + { + MaxWidth = MaxWidth.Medium, + FullWidth = true, + CloseButton = true + }; + } + + private DialogParameters GetDialogParameters() + { + return new DialogParameters + { + ["AuthState"] = AuthState + }; + } + + public override void Dispose() + { + base.Dispose(); + _lockoutCts?.Cancel(); + _lockoutTimer?.Dispose(); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor new file mode 100644 index 00000000..d8eb7138 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor @@ -0,0 +1,27 @@ +๏ปฟ@inject NavigationManager Nav + + + + + + + Access Denied + + + You donโ€™t have permission to view this page. + If you think this is a mistake, sign in with a different account or request access. + + + + Sign In + Go Back + + + + + + UltimateAuth protects this resource based on your session and permissions. + + + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor.cs new file mode 100644 index 00000000..f46ca21f --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/NotAuthorized.razor.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class NotAuthorized +{ + private string LoginHref + { + get + { + var returnUrl = Uri.EscapeDataString(Nav.ToBaseRelativePath(Nav.Uri)); + return $"/login?returnUrl=/{returnUrl}"; + } + } + + private void GoBack() => Nav.NavigateTo("/", replace: false); +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor new file mode 100644 index 00000000..881cae5c --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor @@ -0,0 +1,60 @@ +๏ปฟ@page "/register" +@inherits UAuthFlowPageBase + +@implements IDisposable +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar +@inject IUAuthClientProductInfoProvider ClientProductInfoProvider +@inject IDeviceIdProvider DeviceIdProvider +@inject IDialogService DialogService + + + + + + + + + + + + + + diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor.cs new file mode 100644 index 00000000..db73fd6a --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/Register.razor.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Users.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class Register +{ + private string? _username; + private string? _password; + private string? _passwordCheck; + private string? _email; + private UAuthClientProductInfo? _productInfo; + private MudForm _form = null!; + + protected override async Task OnInitializedAsync() + { + _productInfo = ClientProductInfoProvider.Get(); + } + + private async Task HandleRegisterAsync() + { + await _form.Validate(); + + if (!_form.IsValid) + return; + + var request = new CreateUserRequest + { + UserName = _username, + Password = _password, + Email = _email, + }; + + var result = await UAuthClient.Users.CreateAsync(request); + if (result.IsSuccess) + { + Snackbar.Add("User created successfully.", Severity.Success); + } + else + { + Snackbar.Add(result.ErrorText ?? "Failed to create user.", Severity.Error); + } + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor new file mode 100644 index 00000000..753878b8 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor @@ -0,0 +1,18 @@ +๏ปฟ@page "/reset" +@inherits UAuthFlowPageBase + +@inject IUAuthClient UAuthClient +@inject ISnackbar Snackbar + + + + + + + + + Change Password + + + + \ No newline at end of file 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 new file mode 100644 index 00000000..726c4864 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Pages/ResetCredential.razor.cs @@ -0,0 +1,49 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Contracts; +using MudBlazor; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Pages; + +public partial class ResetCredential +{ + private MudForm _form = null!; + private string? _code; + private string? _newPassword; + private string? _newPasswordCheck; + + private async Task ResetPasswordAsync() + { + await _form.Validate(); + if (!_form.IsValid) + { + Snackbar.Add("Please fix the validation errors.", Severity.Error); + return; + } + + if (_newPassword != _newPasswordCheck) + { + Snackbar.Add("Passwords do not match.", Severity.Error); + return; + } + + var request = new CompleteResetCredentialRequest + { + ResetToken = _code, + NewSecret = _newPassword ?? string.Empty, + Identifier = Identifier // Coming from UAuthFlowPageBase automatically if begin reset is successful + }; + + var result = await UAuthClient.Credentials.CompleteResetMyAsync(request); + + if (result.IsSuccess) + { + Snackbar.Add("Credential reset successfully. Please log in with your new password.", Severity.Success); + Nav.NavigateTo("/login"); + } + else + { + Snackbar.Add(result.Problem?.Detail ?? result.Problem?.Title ?? "Failed to reset credential. Please try again.", Severity.Error); + } + } + + private string PasswordMatch(string arg) => _newPassword != arg ? "Passwords don't match" : string.Empty; +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs new file mode 100644 index 00000000..a8841e99 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Program.cs @@ -0,0 +1,42 @@ +using CodeBeam.UltimateAuth.Client.Blazor.Extensions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.Infrastructure; +using CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using MudBlazor.Services; +using MudExtensions.Services; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddMudServices(o => { + o.SnackbarConfiguration.PreventDuplicates = false; +}); +builder.Services.AddMudExtensions(); +builder.Services.AddScoped(); + + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +builder.Services.AddUltimateAuthClientBlazor(o => +{ + o.Endpoints.BasePath = "https://localhost:6110/auth"; // UAuthHub URL + o.Reauth.Behavior = ReauthBehavior.RaiseEvent; + o.Login.AllowCredentialPost = true; + o.Pkce.ReturnUrl = "https://localhost:6130/home"; // This application domain + path +}); + +builder.Services.AddScoped(); + +builder.Services.AddScoped(sp => +{ + return new HttpClient + { + BaseAddress = new Uri("https://localhost:6120") // Resource API URL + }; +}); + +await builder.Build().RunAsync(); diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json new file mode 100644 index 00000000..0e706d48 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/Properties/launchSettings.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "http://localhost:6131", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", + "applicationUrl": "https://localhost:6130;http://localhost:6131", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs new file mode 100644 index 00000000..f410db79 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/ProductApiService.cs @@ -0,0 +1,78 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Components.WebAssembly.Http; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi; + +public class ProductApiService +{ + private readonly HttpClient _http; + + public ProductApiService(HttpClient http) + { + _http = http; + } + + private HttpRequestMessage CreateRequest(HttpMethod method, string url, object? body = null) + { + var request = new HttpRequestMessage(method, url); + request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + + if (body is not null) + { + request.Content = JsonContent.Create(body); + } + + return request; + } + + public Task>> GetAllAsync() + => SendAsync>(CreateRequest(HttpMethod.Get, "/api/products")); + + public Task> GetAsync(int id) + => SendAsync(CreateRequest(HttpMethod.Get, $"/api/products/{id}")); + + public Task> CreateAsync(SampleProduct product) + => SendAsync(CreateRequest(HttpMethod.Post, $"/api/products", product)); + + public Task> UpdateAsync(int id, SampleProduct product) + => SendAsync(CreateRequest(HttpMethod.Put, $"/api/products/{id}", product)); + + public Task> DeleteAsync(int id) + => SendAsync(CreateRequest(HttpMethod.Delete, $"/api/products/{id}")); + + private async Task> SendAsync(HttpRequestMessage request) + { + var response = await _http.SendAsync(request); + + var result = new UAuthResult + { + Status = (int)response.StatusCode, + IsSuccess = response.IsSuccessStatusCode + }; + + if (response.IsSuccessStatusCode) + { + result.Value = await response.Content.ReadFromJsonAsync(); + return result; + } + + result.Problem = await TryReadProblem(response); + return result; + } + + private async Task TryReadProblem(HttpResponseMessage response) + { + try + { + return await response.Content.ReadFromJsonAsync(); + } + catch + { + return new UAuthProblem + { + Title = response.ReasonPhrase + }; + } + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs new file mode 100644 index 00000000..bf145679 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/ResourceApi/SampleProduct.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm.ResourceApi; + +public class SampleProduct +{ + public int Id { get; set; } + public string? Name { get; set; } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor new file mode 100644 index 00000000..9eaccb01 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/_Imports.razor @@ -0,0 +1,22 @@ +๏ปฟ@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using UltimateAuth.Sample.BlazorStandaloneWasm +@using UltimateAuth.Sample.BlazorStandaloneWasm.Layout + +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Domain +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Runtime +@using CodeBeam.UltimateAuth.Client.Diagnostics +@using CodeBeam.UltimateAuth.Client.Blazor + +@using MudBlazor +@using MudExtensions diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/UltimateAuth-Logo.png b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/UltimateAuth-Logo.png new file mode 100644 index 00000000..5b7282f1 Binary files /dev/null and b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/UltimateAuth-Logo.png differ diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css new file mode 100644 index 00000000..897c71bd --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/css/app.css @@ -0,0 +1,206 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: relative; + display: block; + width: 8rem; + height: 8rem; + margin: 20vh auto 1rem auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +.uauth-stack { + min-height: 60vh; + max-height: calc(100vh - var(--mud-appbar-height)); + width: 30vw; + min-width: 300px; +} + +.uauth-menu-popover { + width: 300px; +} + +.uauth-login-paper { + min-height: 70vh; +} + + .uauth-login-paper.mud-theme-primary { + background: linear-gradient(145deg, var(--mud-palette-primary), rgba(0, 0, 0, 0.85) ); + color: white; + } + +.uauth-brand-glow { + filter: drop-shadow(0 0 25px rgba(255,255,255,0.15)); +} + +.uauth-logo-slide { + animation: uauth-logo-float 30s ease-in-out infinite; +} + +.uauth-text-transform-none .mud-button { + text-transform: none; +} + +.uauth-dialog { + height: 68vh; + max-height: 68vh; + overflow: auto; +} + +.text-secondary { + color: var(--mud-palette-text-secondary); +} + +.uauth-blur { + backdrop-filter: blur(10px); +} + +.uauth-blur-slight { + backdrop-filter: blur(4px); +} + +@keyframes uauth-logo-float { + 0% { + transform: translateY(0) rotateY(0); + } + + 10% { + transform: translateY(0) rotateY(0); + } + + 15% { + transform: translateY(200px) rotateY(360deg); + } + + 35% { + transform: translateY(200px) rotateY(360deg); + } + + 40% { + transform: translateY(200px) rotateY(720deg); + } + + 60% { + transform: translateY(200px) rotateY(720deg); + } + + 65% { + transform: translateY(0) rotateY(360deg); + } + + 85% { + transform: translateY(0) rotateY(360deg); + } + + 90% { + transform: translateY(0) rotateY(0); + } + + 100% { + transform: translateY(0) rotateY(0); + } +} diff --git a/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html new file mode 100644 index 00000000..26cd7ae0 --- /dev/null +++ b/samples/blazor-standalone-wasm/CodeBeam.UltimateAuth.Sample.BlazorStandaloneWasm/wwwroot/index.html @@ -0,0 +1,36 @@ + + + + + + + UltimateAuth.Sample.BlazorStandaloneWasm + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + ๐Ÿ—™ +
+ + + + + + + diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj new file mode 100644 index 00000000..1c868dd7 --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.csproj @@ -0,0 +1,18 @@ +๏ปฟ + + + net10.0 + enable + enable + false + + + + + + + + + + + diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.http b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.http new file mode 100644 index 00000000..102d1c1c --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/CodeBeam.UltimateAuth.Sample.ResourceApi.http @@ -0,0 +1,6 @@ +@CodeBeam.UltimateAuth.Sample.ResourceApi_HostAddress = http://localhost:5038 + +GET {{CodeBeam.UltimateAuth.Sample.ResourceApi_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs new file mode 100644 index 00000000..a59b1f61 --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Program.cs @@ -0,0 +1,25 @@ +using CodeBeam.UltimateAuth.Server.Extensions; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddOpenApi(); + +builder.Services.AddUltimateAuthResourceApi(o => + { + o.UAuthHubBaseUrl = "https://localhost:6110"; + o.AllowedClientOrigins.Add("https://localhost:6130"); + }); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} +app.UseHttpsRedirection(); + +app.UseUltimateAuthResourceApiWithAspNetCore(); + +app.MapControllers(); +app.Run(); diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Properties/launchSettings.json b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Properties/launchSettings.json new file mode 100644 index 00000000..30d4169f --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/Properties/launchSettings.json @@ -0,0 +1,23 @@ +๏ปฟ{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:6121", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:6120;http://localhost:6121", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs new file mode 100644 index 00000000..7d9191f3 --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/AppActions.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Sample.ResourceApi; + +public static class AppActions +{ + public static class Products + { + public static readonly string Read = UAuthActions.Create("products", "read", ActionScope.Self); + + public static readonly string Create = UAuthActions.Create("products", "create", ActionScope.Admin); + + public static readonly string Update = UAuthActions.Create("products", "update", ActionScope.Admin); + + public static readonly string Delete = UAuthActions.Create("products", "delete", ActionScope.Admin); + } +} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs new file mode 100644 index 00000000..10db05bf --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductStore.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.ResourceApi; + +public static class ProductStore +{ + public static List Items = new() { new SampleProduct() { Id = 0, Name = "Test"} }; +} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs new file mode 100644 index 00000000..8f99886a --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/ProductsController.cs @@ -0,0 +1,73 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Errors; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace CodeBeam.UltimateAuth.Sample.ResourceApi; + +[ApiController] +[Route("api/products")] +public class ProductsController : ControllerBase +{ + [HttpGet] + [Authorize(Roles = "Admin")] + //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)] // You can use UAuthActions as permission in ASP.NET Core policy. + public IActionResult GetAll() + { + return Ok(ProductStore.Items); + } + + [HttpGet("{id}")] + [Authorize(Roles = "Admin")] + //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)] + public IActionResult Get(int id) + { + var item = ProductStore.Items.FirstOrDefault(x => x.Id == id); + if (item == null) return NotFound(); + + return Ok(item); + } + + [HttpPost] + [Authorize(Roles = "Admin")] + //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)] + public IActionResult Create(SampleProduct product) + { + var nextId = ProductStore.Items.Any() + ? ProductStore.Items.Max(x => x.Id) + 1 + : 1; + + product.Id = nextId; + ProductStore.Items.Add(product); + + return Ok(product); + } + + [HttpPut("{id}")] + [Authorize(Roles = "Admin")] + //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)] + public IActionResult Update(int id, SampleProduct product) + { + var item = ProductStore.Items.FirstOrDefault(x => x.Id == id); + + if (item == null) + { + throw new UAuthNotFoundException("No product found."); + } + + item.Name = product.Name; + return Ok(product); + } + + [HttpDelete("{id}")] + [Authorize(Roles = "Admin")] + //[Authorize(Policy = UAuthActions.Authorization.Roles.GetAdmin)] + public IActionResult Delete(int id) + { + var item = ProductStore.Items.FirstOrDefault(x => x.Id == id); + if (item == null) return NotFound(); + + ProductStore.Items.Remove(item); + return Ok(item); + } +} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs new file mode 100644 index 00000000..2c75603d --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/SampleData/SampleProduct.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Sample.ResourceApi; + +public class SampleProduct +{ + public int Id { get; set; } + public string Name { get; set; } = default!; +} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.Development.json b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.Development.json new file mode 100644 index 00000000..0c208ae9 --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.json b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.json new file mode 100644 index 00000000..10f68b8c --- /dev/null +++ b/samples/resource-api/CodeBeam.UltimateAuth.Sample.ResourceApi/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj b/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj deleted file mode 100644 index 8004a0dd..00000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/CodeBeam.UltimateAuth.AspNetCore.csproj +++ /dev/null @@ -1,10 +0,0 @@ -๏ปฟ - - - net8.0;net9.0;net10.0 - enable - enable - true - - - diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep b/src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Extensions/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep b/src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Handlers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep b/src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Middlewares/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep b/src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Options/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep b/src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.AspNetCore/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/Authentication/.gitkeep b/src/CodeBeam.UltimateAuth.Client/Authentication/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Authentication/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj deleted file mode 100644 index 8004a0dd..00000000 --- a/src/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj +++ /dev/null @@ -1,10 +0,0 @@ -๏ปฟ - - - net8.0;net9.0;net10.0 - enable - enable - true - - - diff --git a/src/CodeBeam.UltimateAuth.Client/Helpers/.gitkeep b/src/CodeBeam.UltimateAuth.Client/Helpers/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Helpers/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/Internal/.gitkeep b/src/CodeBeam.UltimateAuth.Client/Internal/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Internal/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/Models/.gitkeep b/src/CodeBeam.UltimateAuth.Client/Models/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Models/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Client/Services/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Client/Storage/.gitkeep b/src/CodeBeam.UltimateAuth.Client/Storage/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Client/Storage/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/.gitkeep b/src/CodeBeam.UltimateAuth.Core/Abstractions/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Abstractions/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs new file mode 100644 index 00000000..1dc892e9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthContextFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthContextFactory +{ + AuthContext Create(DateTimeOffset? at = null); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthStateSnapshotFactory.cs new file mode 100644 index 00000000..c846e1e4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Auth/IAuthStateSnapshotFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthStateSnapshotFactory +{ + Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs new file mode 100644 index 00000000..1e12f065 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityManager.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityManager +{ + Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default); + Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default); + Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..e02e6a66 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStore.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthenticationSecurityStateStore +{ + Task GetAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); + + Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default); + + Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default); + + Task DeleteAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStoreFactory.cs new file mode 100644 index 00000000..7a5f7aa9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authentication/IAuthenticationSecurityStateStoreFactory.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions +{ + public interface IAuthenticationSecurityStateStoreFactory + { + IAuthenticationSecurityStateStore Create(TenantKey tenant); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs new file mode 100644 index 00000000..a5b3f69c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessAuthority.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAccessAuthority +{ + AccessDecision Decide(AccessContext context, IEnumerable runtimePolicies); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs new file mode 100644 index 00000000..c043d44d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessInvariant.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAccessInvariant +{ + AccessDecision Decide(AccessContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs new file mode 100644 index 00000000..49a1efad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAccessPolicy.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAccessPolicy +{ + bool AppliesTo(AccessContext context); + AccessDecision Decide(AccessContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs new file mode 100644 index 00000000..4e5eface --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthAuthority.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthAuthority +{ + AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs new file mode 100644 index 00000000..e8e11ca7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityInvariant.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +// TODO: Add ClockSkewInvariant to handle cases where client and server clocks are not synchronized, which can lead to valid tokens being rejected due to "not valid yet" or "expired" errors. This invariant would check the token's "nbf" (not before) and "exp" (expiration) claims against the current time, allowing for a configurable clock skew (e.g., 5 minutes) to accommodate minor discrepancies in system clocks. +public interface IAuthorityInvariant +{ + AccessDecisionResult Decide(AuthContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs new file mode 100644 index 00000000..5d2bc41d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Authority/IAuthorityPolicy.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IAuthorityPolicy +{ + bool AppliesTo(AuthContext context); + AccessDecisionResult Decide(AuthContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs new file mode 100644 index 00000000..bd5c1d72 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Device/IUserAgentParser.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IUserAgentParser +{ + UserAgentInfo Parse(string? userAgent); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs new file mode 100644 index 00000000..12995e3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IEntitySnapshot.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IEntitySnapshot +{ + T Snapshot(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/ITenantEntity.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/ITenantEntity.cs new file mode 100644 index 00000000..c5f57319 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/ITenantEntity.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ITenantEntity +{ + TenantKey Tenant { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs new file mode 100644 index 00000000..893206e2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Entity/IVersionedEntity.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IVersionedEntity +{ + long Version { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs new file mode 100644 index 00000000..3d5a5817 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCapabilities.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IHubCapabilities +{ + bool SupportsPkce { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs new file mode 100644 index 00000000..f3e075b4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubCredentialResolver.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IHubCredentialResolver +{ + Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs new file mode 100644 index 00000000..0096d891 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Hub/IHubFlowReader.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IHubFlowReader +{ + Task GetStateAsync(HubSessionId hubSessionId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs new file mode 100644 index 00000000..0d40c708 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IClock.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Provides an abstracted time source for the system. +/// Used to improve testability and ensure consistent time handling. +/// +// TODO: Add UnixTimeSeconds, TimeZone-aware Now, etc. if needed. +public interface IClock +{ + DateTimeOffset UtcNow { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs new file mode 100644 index 00000000..f2fd00f8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ISeedContributor.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Contributes seed data for a specific domain (Users, Credentials, Authorization, etc). +/// Intended for dev/test/in-memory environments. +/// +public interface ISeedContributor +{ + /// + /// Execution order relative to other contributors. + /// Lower numbers run first. + /// + int Order { get; } + + Task SeedAsync(TenantKey tenant, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs new file mode 100644 index 00000000..1096f980 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/ITokenHasher.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Hashes and verifies sensitive tokens. +/// Used for refresh tokens, session ids, opaque tokens. +/// +public interface ITokenHasher +{ + string Hash(string plaintext); + bool Verify(string hash, string plaintext); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs new file mode 100644 index 00000000..039a8216 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Infrastructure/IUAuthPasswordHasher.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Securely hashes and verifies user passwords. +/// Designed for slow, adaptive, memory-hard algorithms +/// such as Argon2 or bcrypt. +/// +public interface IUAuthPasswordHasher +{ + string Hash(string password); + bool Verify(string hash, string secret); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs new file mode 100644 index 00000000..a03e1256 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IJwtTokenGenerator.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Low-level JWT creation abstraction. +/// Can be replaced for asymmetric keys, external KMS, etc. +/// +public interface IJwtTokenGenerator +{ + string CreateToken(UAuthJwtTokenDescriptor descriptor); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs new file mode 100644 index 00000000..73b4cfc8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/INumericCodeGenerator.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface INumericCodeGenerator +{ + string Generate(int digits = 6); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs new file mode 100644 index 00000000..63e53b5c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/IOpaqueTokenGenerator.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Generates cryptographically secure random tokens +/// for opaque identifiers, refresh tokens, session ids. +/// +public interface IOpaqueTokenGenerator +{ + string Generate(); + string GenerateJwtId(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs new file mode 100644 index 00000000..db00ab92 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Issuers/ISessionIssuer.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISessionIssuer +{ + Task IssueSessionAsync(SessionIssuanceContext context, CancellationToken cancellationToken = default); + + Task RotateSessionAsync(SessionRotationContext context, CancellationToken cancellationToken = default); + + Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken cancellationToken = default); + + Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken cancellationToken = default); + + Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default); + + Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at,CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs new file mode 100644 index 00000000..b51990e9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserClaimsProvider.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core; + +public interface IUserClaimsProvider +{ + Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs new file mode 100644 index 00000000..1a222d83 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverter.cs @@ -0,0 +1,48 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Defines conversion logic for transforming user identifiers between +/// strongly typed values, string representations, and binary formats. +/// Implementations enable consistent storage, token serialization, +/// and multitenant key partitioning. +/// Returned string must be stable and culture-invariant. +/// Implementations must be deterministic and reversible. +/// +public interface IUserIdConverter +{ + /// + /// Converts the typed user identifier into its canonical string representation. + /// + /// The user identifier to convert. + /// A stable and reversible string representation of the identifier. + string ToCanonicalString(TUserId id); + + /// + /// Converts the typed user identifier into a binary representation suitable for efficient storage or hashing operations. + /// + /// The user identifier to convert. + /// A byte array representing the identifier. + byte[] ToBytes(TUserId id); + + /// + /// Reconstructs a typed user identifier from its string representation. + /// + /// The string-encoded identifier. + /// The reconstructed user identifier. + /// + /// Thrown when the input value cannot be parsed into a valid identifier. + /// + TUserId FromString(string value); + bool TryFromString(string value, out TUserId userId); + + /// + /// Reconstructs a typed user identifier from its binary representation. + /// + /// The byte array containing the encoded identifier. + /// The reconstructed user identifier. + /// + /// Thrown when the input binary value cannot be parsed into a valid identifier. + /// + TUserId FromBytes(byte[] binary); + bool TryFromBytes(byte[] binary, out TUserId userId); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs new file mode 100644 index 00000000..bd8dd807 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdConverterResolver.cs @@ -0,0 +1,22 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Resolves the appropriate instance +/// for a given user identifier type. Used internally by UltimateAuth to +/// ensure consistent serialization and parsing of user IDs across all components. +/// +public interface IUserIdConverterResolver +{ + /// + /// Retrieves the registered for the specified user ID type. + /// + /// The type of the user identifier. + /// + /// A converter capable of transforming the user ID to and from its string + /// and binary representations. + /// + /// + /// Thrown if no converter has been registered for the requested user ID type. + /// + IUserIdConverter GetConverter(string? purpose = null); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs new file mode 100644 index 00000000..c72f650f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Principals/IUserIdFactory.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Responsible for creating new user identifiers. +/// This abstraction allows UltimateAuth to remain +/// independent from the concrete user ID type. +/// +/// User identifier type. +public interface IUserIdFactory +{ + /// + /// Creates a new unique user identifier. + /// + TUserId Create(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs new file mode 100644 index 00000000..7006f2d1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IAccessTokenIdStore.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Optional persistence for access token identifiers (jti). +/// Used for revocation and replay protection. +/// +public interface IAccessTokenIdStore +{ + Task StoreAsync(TenantKey tenant, string jti, DateTimeOffset expiresAt, CancellationToken ct = default); + + Task IsRevokedAsync(TenantKey tenant, string jti, CancellationToken ct = default); + + Task RevokeAsync(TenantKey tenant, string jti, DateTimeOffset revokedAt, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs new file mode 100644 index 00000000..095beb5e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStore.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IRefreshTokenStore +{ + Task ExecuteAsync(Func action, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); + + Task StoreAsync(RefreshToken token, CancellationToken ct = default); + + Task FindByHashAsync(string tokenHash, CancellationToken ct = default); + + Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default); + + Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default); + + Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default); + + Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e37debc7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IRefreshTokenStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IRefreshTokenStoreFactory +{ + IRefreshTokenStore Create(TenantKey tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs new file mode 100644 index 00000000..fc29ea73 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs @@ -0,0 +1,40 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISessionStore +{ + Task ExecuteAsync(Func action, CancellationToken ct = default); + Task ExecuteAsync(Func> action, CancellationToken ct = default); + + Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default); + Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default); + Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default); + public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + public Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default); + Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + + Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default); + Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default); + Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default); + Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task RevokeOtherChainsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default); + Task RevokeAllChainsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default); + Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default); + + Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default); + Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default); + Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default); + Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default); + Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default); + Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default); + + Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default); + Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default); + Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default); + Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs new file mode 100644 index 00000000..72cd6b3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStoreFactory.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Provides a factory abstraction for creating tenant-scoped session store +/// instances capable of persisting sessions, chains, and session roots. +/// Implementations typically resolve concrete types from the dependency injection container. +/// +public interface ISessionStoreFactory +{ + /// + /// Creates and returns a session store instance for the specified user ID type within the given tenant context. + /// + /// + /// An implementation able to perform session persistence operations. + /// + ISessionStore Create(TenantKey tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs new file mode 100644 index 00000000..ecbf8540 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISoftDeleteable.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface ISoftDeletable +{ + bool IsDeleted { get; } + DateTimeOffset? DeletedAt { get; } + + T MarkDeleted(DateTimeOffset now); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs new file mode 100644 index 00000000..f7a02f0f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/IVersionedStore.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +// TODO: Add IQueryStore +public interface IVersionedStore +{ + Task GetAsync(TKey key, CancellationToken ct = default); + + Task ExistsAsync(TKey key, CancellationToken ct = default); + + Task AddAsync(TEntity entity, CancellationToken ct = default); + + Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default); + + Task DeleteAsync(TKey key, long expectedVersion, DeleteMode deleteMode, DateTimeOffset now, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs new file mode 100644 index 00000000..1d335fc3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/User/IUserRuntimeStateProvider.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Runtime, read-only user access for authentication flows. +/// Not a domain store. +/// +public interface IUserRuntimeStateProvider +{ + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs new file mode 100644 index 00000000..404422a8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IJwtValidator.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +/// +/// Validates access tokens (JWT or opaque) and resolves +/// the authenticated user context. +/// +public interface IJwtValidator +{ + Task> ValidateAsync(string token, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs new file mode 100644 index 00000000..e30f68f6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Abstractions/Validators/IRefreshTokenValidator.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Abstractions; + +public interface IRefreshTokenValidator +{ + Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs new file mode 100644 index 00000000..a7a9e51d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/AssemblyVisibility.cs @@ -0,0 +1,10 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj index 8004a0dd..1b60c23c 100644 --- a/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj +++ b/src/CodeBeam.UltimateAuth.Core/CodeBeam.UltimateAuth.Core.csproj @@ -2,9 +2,25 @@ net8.0;net9.0;net10.0 - enable - enable - true + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Core + + Core domain primitives and abstractions for UltimateAuth. This package is not intended to be installed directly in most applications. Use CodeBeam.UltimateAuth.Server or Client packages instead. + authentication;authorization;identity;security;oauth;login;session;auth;refresh-token;pkce;dotnet;aspnetcore;blazor;maui;auth-framework + logo.png + README.md + + + + + + + + + + + diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs new file mode 100644 index 00000000..f8c98d61 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Access/AccessScope.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum ActionScope +{ + Anonymous = 0, + Self = 10, + Admin = 20, + System = 30 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthFlowPayload.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthFlowPayload.cs new file mode 100644 index 00000000..ac01ed69 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthFlowPayload.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthFlowPayload +{ + public int V { get; init; } = 1; + + public AuthFlowType Flow { get; init; } + + public string Status { get; init; } = default!; + + public AuthFailureReason? Reason { get; init; } + + public long? LockoutUntil { get; init; } + + public int? RemainingAttempts { get; init; } + + public DateTimeOffset? LockoutUntilUtc => LockoutUntil is long unix + ? DateTimeOffset.FromUnixTimeSeconds(unix) + : null; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs new file mode 100644 index 00000000..4f3812bf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthIdentitySnapshot.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthIdentitySnapshot +{ + public required UserKey UserKey { get; init; } + public required TenantKey Tenant { get; init; } + public string? PrimaryUserName { get; init; } + public string? DisplayName { get; init; } + public string? PrimaryEmail { get; init; } + public string? PrimaryPhone { get; init; } + public DateTimeOffset? AuthenticatedAt { get; init; } + public SessionState? SessionState { get; init; } + public string? TimeZone { get; init; } + public UserStatus UserStatus { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthStateSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthStateSnapshot.cs new file mode 100644 index 00000000..833102d1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Auth/AuthStateSnapshot.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents the authenticated security snapshot returned by the server. +/// +/// This object is immutable and represents a validated authentication state +/// at a specific point in time. It contains: +/// +/// - Identity information (who the subject is) +/// - Authorization claims (what the subject can do) +/// +/// This is not a live security context and must not be treated as a trust boundary. +/// Always validate on the server. +/// +public sealed record AuthStateSnapshot +{ + /// + /// Authentication identity information such as UserKey, Tenant and authentication metadata. + /// + public required AuthIdentitySnapshot Identity { get; init; } + + /// + /// Authorization claims associated with the identity. These are security claims, not profile or display data. + /// + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs new file mode 100644 index 00000000..7fa62828 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessContext.cs @@ -0,0 +1,104 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class AccessContext +{ + // Actor + public UserKey? ActorUserKey { get; init; } + public TenantKey ActorTenant { get; init; } + public bool IsAuthenticated { get; init; } + public bool IsSystemActor { get; init; } + public SessionChainId? ActorChainId { get; } + + // Target + public string? Resource { get; init; } + public UserKey? TargetUserKey { get; init; } + public TenantKey ResourceTenant { get; init; } + + public string Action { get; init; } = default!; + public IReadOnlyDictionary Attributes { get; init; } = EmptyAttributes.Instance; + + public bool IsCrossTenant => !string.Equals(ActorTenant, ResourceTenant, StringComparison.Ordinal); + public bool IsSelfAction => ActorUserKey != null && TargetUserKey != null && string.Equals(ActorUserKey.Value, TargetUserKey.Value, StringComparison.Ordinal); + public bool HasActor => ActorUserKey != null; + public bool HasTarget => TargetUserKey != null; + + public UserKey GetTargetUserKey() + { + if (TargetUserKey is not UserKey targetUserKey) + throw new UAuthNotFoundException("Target user is not found."); + + return targetUserKey; + } + + internal AccessContext( + UserKey? actorUserKey, + TenantKey actorTenant, + bool isAuthenticated, + bool isSystemActor, + SessionChainId? actorChainId, + string? resource, + UserKey? targetUserKey, + TenantKey resourceTenant, + string action, + IReadOnlyDictionary attributes) + { + ActorUserKey = actorUserKey; + ActorTenant = actorTenant; + IsAuthenticated = isAuthenticated; + IsSystemActor = isSystemActor; + ActorChainId = actorChainId; + + Resource = resource; + TargetUserKey = targetUserKey; + ResourceTenant = resourceTenant; + + Action = action; + Attributes = attributes; + } + + public AccessContext WithAttribute(string key, object value) + { + var merged = new Dictionary(Attributes) + { + [key] = value + }; + + return new AccessContext( + ActorUserKey, + ActorTenant, + IsAuthenticated, + IsSystemActor, + ActorChainId, + Resource, + TargetUserKey, + ResourceTenant, + Action, + merged + ); + } +} + +internal sealed class EmptyAttributes : IReadOnlyDictionary +{ + public static readonly EmptyAttributes Instance = new(); + + private EmptyAttributes() { } + + public IEnumerable Keys => Array.Empty(); + public IEnumerable Values => Array.Empty(); + public int Count => 0; + public object this[string key] => throw new KeyNotFoundException(); + public bool ContainsKey(string key) => false; + public bool TryGetValue(string key, out object value) + { + value = default!; + return false; + } + public IEnumerator> GetEnumerator() => Enumerable.Empty>().GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs new file mode 100644 index 00000000..fa89076e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecision.cs @@ -0,0 +1,36 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AccessDecision +{ + public bool IsAllowed { get; } + public bool RequiresReauthentication { get; } + public string? DenyReason { get; } + + private AccessDecision(bool isAllowed, bool requiresReauthentication, string? denyReason) + { + IsAllowed = isAllowed; + RequiresReauthentication = requiresReauthentication; + DenyReason = denyReason; + } + + public static AccessDecision Allow() + => new( + isAllowed: true, + requiresReauthentication: false, + denyReason: null); + + public static AccessDecision Deny(string reason) + => new( + isAllowed: false, + requiresReauthentication: false, + denyReason: reason); + + public static AccessDecision ReauthenticationRequired(string? reason = null) + => new( + isAllowed: false, + requiresReauthentication: true, + denyReason: reason); + + public bool IsDenied => + !IsAllowed && !RequiresReauthentication; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs new file mode 100644 index 00000000..a1e2002b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AccessDecisionResult.cs @@ -0,0 +1,26 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class AccessDecisionResult +{ + public AuthorizationDecision Decision { get; } + public string? Reason { get; } + + private AccessDecisionResult(AuthorizationDecision decision, string? reason) + { + Decision = decision; + Reason = reason; + } + + public static AccessDecisionResult Allow() + => new(AuthorizationDecision.Allow, null); + + public static AccessDecisionResult Deny(string reason) + => new(AuthorizationDecision.Deny, reason); + + public static AccessDecisionResult Challenge(string reason) + => new(AuthorizationDecision.Challenge, reason); + + public bool IsAllowed => Decision == AuthorizationDecision.Allow; + public bool IsDenied => Decision == AuthorizationDecision.Deny; + public bool RequiresChallenge => Decision == AuthorizationDecision.Challenge; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs new file mode 100644 index 00000000..0424ad16 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthContext.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthContext +{ + public UAuthClientProfile ClientProfile { get; set; } + + public TenantKey Tenant { get; init; } + + public AuthOperation Operation { get; init; } + + public UAuthMode Mode { get; init; } + + public SessionSecurityContext? Session { get; init; } + + public required DeviceContext Device { get; init; } + + public DateTimeOffset At { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs new file mode 100644 index 00000000..2e6bb373 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthOperation.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum AuthOperation +{ + Login = 0, + Access = 10, + ResourceAccess = 20, + Refresh = 30, + Revoke = 40, + Logout = 50, + System = 100, +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs new file mode 100644 index 00000000..4634154a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/AuthorizationDecision.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum AuthorizationDecision +{ + Allow = 0, + Deny = 10, + Challenge = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs new file mode 100644 index 00000000..10c097d5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceInfo.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class DeviceInfo +{ + public required DeviceId DeviceId { get; init; } + + // TODO: Implement device type and device limits + /// + /// Device type that can be used for device limits. Sends with header "X-Device-Type" or form field "device_type". Examples: "web", "mobile", "desktop", "tablet", "iot". + /// + public string? DeviceType { get; init; } + + /// + /// Operating system information (e.g. iOS 17, Android 14, Windows 11). + /// + public string? OperatingSystem { get; init; } + + /// + /// Browser name/version for web clients. + /// + public string? Browser { get; init; } + + /// + /// High-level platform classification (web, mobile, desktop, iot). + /// Used for analytics and policy decisions. + /// + public string? Platform { get; init; } + + /// + /// Raw user-agent string (optional). + /// + public string? UserAgent { get; init; } + + /// + /// Client IP address at session creation or last validation. + /// + public string? IpAddress { get; init; } + + /// + /// Optional fingerprint hash provided by client. + /// Not trusted by default. + /// + public string? Fingerprint { get; init; } + + /// + /// Arbitrary metadata for future extensions. + /// + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs new file mode 100644 index 00000000..1c1e11f2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Authority/DeviceMismatchBehavior.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum DeviceMismatchBehavior +{ + Reject = 0, + Allow = 10, + AllowAndRebind = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs new file mode 100644 index 00000000..214d213a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/CaseHandling.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum CaseHandling +{ + Preserve = 0, + ToLower = 10, + ToUpper = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs new file mode 100644 index 00000000..8e95af9e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/DeleteMode.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum DeleteMode +{ + Soft = 0, + Hard = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs new file mode 100644 index 00000000..1fee5de4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/IUAuthTryResult.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public interface IUAuthTryResult +{ + bool Success { 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 new file mode 100644 index 00000000..1dc6aed7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PageRequest.cs @@ -0,0 +1,30 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public record PageRequest +{ + public int PageNumber { get; init; } = 1; + public int PageSize { get; init; } = 250; + + public string? SortBy { get; init; } + public bool Descending { get; init; } + + public int MaxPageSize { get; init; } = 1000; + + public PageRequest Normalize() + { + var page = PageNumber <= 0 ? 1 : PageNumber; + var size = PageSize <= 0 ? 250 : PageSize; + + if (size > MaxPageSize) + size = MaxPageSize; + + return new PageRequest + { + PageNumber = page, + PageSize = size, + SortBy = SortBy, + Descending = Descending, + MaxPageSize = MaxPageSize + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs new file mode 100644 index 00000000..854eafa4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/PagedResult.cs @@ -0,0 +1,23 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class PagedResult +{ + public IReadOnlyList Items { get; init; } + public int TotalCount { get; init; } + public int PageNumber { get; init; } + public int PageSize { get; init; } + public string? SortBy { get; init; } + public bool Descending { get; init; } + + public bool HasNext => PageNumber * PageSize < TotalCount; + + public PagedResult(IReadOnlyList items, int totalCount, int pageNumber, int pageSize, string? sortBy, bool descending) + { + Items = items; + TotalCount = totalCount; + PageNumber = pageNumber; + PageSize = pageSize; + SortBy = sortBy; + Descending = descending; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs new file mode 100644 index 00000000..a5f692f8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthProblem.cs @@ -0,0 +1,13 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +// Based on https://datatracker.ietf.org/doc/html/rfc7807 +public sealed class UAuthProblem +{ + public string? Type { get; set; } + public string? Title { get; set; } + public int? Status { get; set; } + public string? Detail { get; set; } + public string? TraceId { get; set; } + + public Dictionary? Extensions { get; init; } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs new file mode 100644 index 00000000..2a674d30 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthResult.cs @@ -0,0 +1,35 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public class UAuthResult +{ + public bool IsSuccess { get; init; } + public int Status { get; init; } + public string? CorrelationId { get; init; } + public string? TraceId { get; init; } + + public UAuthProblem? Problem { get; set; } + + public HttpStatusInfo Http => new(Status); + + public string? ErrorText => Problem?.Detail ?? Problem?.Title; + + public sealed class HttpStatusInfo + { + private readonly int _status; + + internal HttpStatusInfo(int status) + { + _status = status; + } + + public bool IsBadRequest => _status == 400; + public bool IsUnauthorized => _status == 401; + public bool IsForbidden => _status == 403; + public bool IsConflict => _status == 409; + } +} + +public sealed class UAuthResult : UAuthResult +{ + public T? Value { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs new file mode 100644 index 00000000..08a742da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Common/UAuthValidationError.cs @@ -0,0 +1,5 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record UAuthValidationError( + string Code, + string? Field = null); \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs new file mode 100644 index 00000000..32da871e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ExternalLoginRequest.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +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; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs new file mode 100644 index 00000000..7d39fc7d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuation.cs @@ -0,0 +1,19 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LoginContinuation +{ + /// + /// Gets the type of login continuation required. + /// + public LoginContinuationType Type { get; init; } + + /// + /// Opaque continuation token used to resume the login flow. + /// + public string ContinuationToken { get; init; } = default!; + + /// + /// Optional hint for UX (e.g. "Enter MFA code", "Verify device"). + /// + public string? Hint { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs new file mode 100644 index 00000000..33e3269f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginContinuationType.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LoginContinuationType +{ + Mfa = 0, + Pkce = 10, + External = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs new file mode 100644 index 00000000..ae45a071 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginRequest.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LoginRequest +{ + public string Identifier { get; init; } = default!; + public string Secret { get; init; } = default!; + public CredentialType Factor { get; init; } = CredentialType.Password; + public IReadOnlyDictionary? Metadata { get; init; } + + /// + /// Hint to request access/refresh tokens when the server mode supports it. + /// Server policy may still ignore this. + /// + public bool RequestTokens { get; init; } = true; + public string? PreviewReceipt { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs new file mode 100644 index 00000000..f445bf58 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginResult.cs @@ -0,0 +1,53 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LoginResult +{ + public LoginStatus Status { get; init; } + public AuthSessionId? SessionId { get; init; } + public AccessToken? AccessToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } + public LoginContinuation? Continuation { get; init; } + public AuthFailureReason? FailureReason { get; init; } + public DateTimeOffset? LockoutUntilUtc { get; init; } + public int? RemainingAttempts { get; init; } + + public bool IsSuccess => Status == LoginStatus.Success; + public bool RequiresContinuation => Continuation is not null; + public bool RequiresMfa => Continuation?.Type == LoginContinuationType.Mfa; + public bool RequiresPkce => Continuation?.Type == LoginContinuationType.Pkce; + + public static LoginResult Failed(AuthFailureReason? reason = null, DateTimeOffset? lockoutUntilUtc = null, int? remainingAttempts = null) + => new() + { + Status = LoginStatus.Failed, + FailureReason = reason, + LockoutUntilUtc = lockoutUntilUtc, + RemainingAttempts = remainingAttempts + }; + + public static LoginResult Success(AuthSessionId sessionId, AuthTokens? tokens = null) + => new() + { + Status = LoginStatus.Success, + SessionId = sessionId, + AccessToken = tokens?.AccessToken, + RefreshToken = tokens?.RefreshToken + }; + + public static LoginResult SuccessPreview() + { + return new LoginResult + { + Status = LoginStatus.Success + }; + } + + public static LoginResult Continue(LoginContinuation continuation) + => new() + { + Status = LoginStatus.RequiresContinuation, + Continuation = continuation + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs new file mode 100644 index 00000000..95802a57 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/LoginStatus.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LoginStatus +{ + Success = 0, + RequiresContinuation = 10, + Failed = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs new file mode 100644 index 00000000..f5089e7a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthRequest.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +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 new file mode 100644 index 00000000..a047ff1d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/ReauthResult.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +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 new file mode 100644 index 00000000..f91c14ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/TryLoginResult.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TryLoginResult : IUAuthTryResult +{ + public bool Success { get; init; } + public AuthFailureReason? Reason { get; init; } + public int? RemainingAttempts { get; init; } + public DateTimeOffset? LockoutUntilUtc { get; init; } + public bool RequiresMfa { get; init; } + public string? PreviewReceipt { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs new file mode 100644 index 00000000..3a386fc8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Login/UAuthLoginType.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum UAuthLoginType +{ + Password = 0, + Pkce = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs new file mode 100644 index 00000000..052ef9f6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutAllRequest.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +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. + /// + public bool ExceptCurrent { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs new file mode 100644 index 00000000..b8612931 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutReason.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum LogoutReason +{ + UserIntend = 0, + Explicit = 10, + SessionExpired = 20, + SecurityPolicy = 30, + AdminForced = 40, + TenantDisabled = 50 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs new file mode 100644 index 00000000..9878f927 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutRequest.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LogoutRequest +{ + public AuthSessionId SessionId { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs new file mode 100644 index 00000000..00cd98ba --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Logout/LogoutResponse.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record LogoutResponse +{ + public bool Success { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs new file mode 100644 index 00000000..536ed568 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/BeginMfaRequest.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +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 new file mode 100644 index 00000000..7aa5d7f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/CompleteMfaRequest.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +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 new file mode 100644 index 00000000..c0a4aad6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Mfa/MfaChallengeResult.cs @@ -0,0 +1,7 @@ +๏ปฟ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. +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs new file mode 100644 index 00000000..39068268 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeCommand.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record PkceAuthorizeCommand +{ + public required string CodeChallenge { get; init; } + public required string ChallengeMethod { get; init; } = "S256"; + public required DeviceContext Device { get; init; } + public string? RedirectUri { get; init; } + + public UAuthClientProfile ClientProfile { get; init; } + public TenantKey Tenant { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs new file mode 100644 index 00000000..b9f80bfa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceAuthorizeResponse.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class PkceAuthorizeResponse +{ + public required string AuthorizationCode { get; init; } + public int ExpiresIn { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs new file mode 100644 index 00000000..b951e1ec --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteRequest.cs @@ -0,0 +1,22 @@ +๏ปฟusing System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record PkceCompleteRequest +{ + [JsonPropertyName("authorization_code")] + public required string AuthorizationCode { get; init; } + + [JsonPropertyName("code_verifier")] + public required string CodeVerifier { get; init; } + + + public required string Identifier { get; init; } + public required string Secret { get; init; } + + [JsonPropertyName("return_url")] + public string ReturnUrl { get; init; } + + [JsonPropertyName("hub_session_id")] + public string HubSessionId { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs new file mode 100644 index 00000000..bc16b023 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/PkceCompleteResult.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record PkceCompleteResult +{ + public bool Success { get; init; } + + public AuthFailureReason? FailureReason { get; init; } + + public LoginResult? LoginResult { get; init; } + + public bool InvalidPkce { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs new file mode 100644 index 00000000..aef8a634 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Pkce/TryPkceLoginResult.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TryPkceLoginResult : IUAuthTryResult +{ + public bool Success { get; init; } + public AuthFailureReason? Reason { get; init; } + public int? RemainingAttempts { get; init; } + public DateTimeOffset? LockoutUntilUtc { get; init; } + public bool RequiresMfa { get; init; } + public bool RetryWithNewPkce { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs new file mode 100644 index 00000000..321cb061 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowRequest.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshFlowRequest +{ + public AuthSessionId? SessionId { get; init; } + public string? RefreshToken { get; init; } + public required DeviceContext Device { get; init; } + public SessionTouchMode TouchMode { get; init; } = SessionTouchMode.IfNeeded; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs new file mode 100644 index 00000000..59b18af1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshFlowResult.cs @@ -0,0 +1,39 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class RefreshFlowResult +{ + public bool Succeeded { get; init; } + public RefreshOutcome Outcome { get; init; } + + public AuthSessionId? SessionId { get; init; } + public AccessToken? AccessToken { get; init; } + public RefreshTokenInfo? RefreshToken { get; init; } + + public static RefreshFlowResult ReauthRequired() + { + return new RefreshFlowResult + { + Succeeded = false, + Outcome = RefreshOutcome.ReauthRequired + }; + } + + public static RefreshFlowResult Success( + RefreshOutcome outcome, + AuthSessionId? sessionId = null, + AccessToken? accessToken = null, + RefreshTokenInfo? refreshToken = null) + { + return new RefreshFlowResult + { + Succeeded = true, + Outcome = outcome, + SessionId = sessionId, + AccessToken = accessToken, + RefreshToken = refreshToken + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs new file mode 100644 index 00000000..731248f6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshStrategy.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum RefreshStrategy +{ + NotSupported = 0, + SessionOnly = 10, // PureOpaque + TokenOnly = 20, // PureJwt + TokenWithSessionCheck = 30, // SemiHybrid + SessionAndToken = 40 // Hybrid +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs new file mode 100644 index 00000000..a3cea858 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenPersistence.cs @@ -0,0 +1,17 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum RefreshTokenPersistence +{ + /// + /// Refresh token store'a yazฤฑlฤฑr. + /// Login, first-issue gibi normal akฤฑลŸlar iรงin. + /// + Persist = 0, + + /// + /// Refresh token store'a yazฤฑlmaz. + /// Rotation gibi รถzel akฤฑลŸlarda, + /// caller tarafฤฑndan kontrol edilir. + /// + DoNotPersist = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs new file mode 100644 index 00000000..a7cda668 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Refresh/RefreshTokenValidationContext.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenValidationContext +{ + public TenantKey Tenant { get; init; } + public required string RefreshToken { get; init; } + public DateTimeOffset Now { get; init; } + + public required DeviceContext Device { get; init; } + public AuthSessionId? ExpectedSessionId { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs new file mode 100644 index 00000000..8d764209 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Revoke/RevokeResult.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RevokeResult +{ + public bool CurrentChain { get; init; } + public bool RootRevoked { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs new file mode 100644 index 00000000..7a617737 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/AuthValidationResult.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record AuthValidationResult +{ + public required SessionState State { get; init; } + public AuthStateSnapshot? Snapshot { get; init; } + + public bool IsValid => State == SessionState.Active; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs new file mode 100644 index 00000000..79654c8c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/AuthSnapshotInfo.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class AuthSnapshotInfo +{ + public IdentityInfo? Identity { get; set; } + + public ClaimsInfo? Claims { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs new file mode 100644 index 00000000..005895b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/ClaimsInfo.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class ClaimsInfo +{ + public Dictionary Claims { get; set; } = new(); + + public string[] Roles { get; set; } = Array.Empty(); + + public string[] Permissions { get; set; } = Array.Empty(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs new file mode 100644 index 00000000..4f008972 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/IdentityInfo.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class IdentityInfo +{ + public string Tenant { get; set; } = default!; + + public string? UserKey { get; set; } + + public DateTimeOffset? AuthenticatedAt { get; set; } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetail.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetail.cs new file mode 100644 index 00000000..d8977be4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainDetail.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class SessionChainDetail +{ + public SessionChainId ChainId { get; init; } + + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; init; } + + public SessionChainState State { get; init; } + + public int RotationCount { get; init; } + public int TouchCount { get; init; } + + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + + public AuthSessionId? ActiveSessionId { get; init; } + + public IReadOnlyList Sessions { get; init; } = []; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs new file mode 100644 index 00000000..3782b0c6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionChainSummary +{ + public required SessionChainId ChainId { get; init; } + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? LastSeenAt { get; init; } + public int RotationCount { get; init; } + public int TouchCount { get; init; } + public bool IsRevoked { get; init; } + public DateTimeOffset? RevokedAt { get; init; } + public AuthSessionId? ActiveSessionId { get; init; } + public bool IsCurrentDevice { get; init; } + public SessionChainState State { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfo.cs new file mode 100644 index 00000000..a04eac65 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionInfo.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionInfo( + AuthSessionId SessionId, + DateTimeOffset CreatedAt, + DateTimeOffset ExpiresAt, + bool IsRevoked + ); diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs new file mode 100644 index 00000000..c4519080 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionValidationInfo.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class SessionValidationInfo +{ + public int State { get; set; } = default!; + + public bool IsValid { get; set; } + + public AuthSnapshotInfo? Snapshot { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs new file mode 100644 index 00000000..6a76788b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/IssuedSession.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents the result of a session issuance operation. +/// +public sealed class IssuedSession +{ + /// + /// The issued domain session. + /// + public required UAuthSession Session { get; init; } + + /// + /// Opaque session identifier returned to the client. + /// + public required string OpaqueSessionId { get; init; } + + /// + /// Indicates whether this issuance is metadata-only + /// (used in SemiHybrid mode). + /// + public bool IsMetadataOnly { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs new file mode 100644 index 00000000..42c51971 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/ResolvedRefreshSession.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record ResolvedRefreshSession +{ + public bool IsValid { get; init; } + public bool IsReuseDetected { get; init; } + + public UAuthSession? Session { get; init; } + public UAuthSessionChain? Chain { get; init; } + + private ResolvedRefreshSession() { } + + public static ResolvedRefreshSession Invalid() + => new() + { + IsValid = false + }; + + public static ResolvedRefreshSession Reused() + => new() + { + IsValid = false, + IsReuseDetected = true + }; + + public static ResolvedRefreshSession Valid(UAuthSession session, UAuthSessionChain chain) + => new() + { + IsValid = true, + Session = session, + Chain = chain + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs new file mode 100644 index 00000000..0426b18a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionContext.cs @@ -0,0 +1,27 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Lightweight session context resolved from the incoming request. +/// Does NOT load or validate the session. +/// Used only by middleware and engines as input. +/// +public sealed class SessionContext +{ + public AuthSessionId? SessionId { get; } + public TenantKey? Tenant { get; } + + public bool IsAnonymous => SessionId is null; + + private SessionContext(AuthSessionId? sessionId, TenantKey? tenant) + { + SessionId = sessionId; + Tenant = tenant; + } + + public static SessionContext Anonymous() => new(null, null); + + public static SessionContext FromSessionId(AuthSessionId sessionId, TenantKey tenant) => new(sessionId, tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs new file mode 100644 index 00000000..1b7886a7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionIssuanceContext.cs @@ -0,0 +1,32 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents the context in which a session is issued +/// (login, refresh, reauthentication). +/// +public sealed class SessionIssuanceContext +{ + public TenantKey Tenant { get; init; } + public required UserKey UserKey { get; init; } + public required DeviceContext Device { get; init; } + public DateTimeOffset Now { get; init; } + public ClaimsSnapshot? Claims { get; init; } + public required SessionMetadata Metadata { get; init; } + public required UAuthMode Mode { get; init; } + + /// + /// Optional chain identifier. + /// If null, a new chain will be created. + /// If provided, session will be issued under the existing chain. + /// + public SessionChainId? ChainId { get; init; } + + /// + /// Indicates that authentication has already been completed. + /// This context MUST NOT be constructed from raw credentials. + /// + public bool IsAuthenticated { get; init; } = true; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs new file mode 100644 index 00000000..9d5c578b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRefreshResult.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionRefreshResult +{ + public SessionRefreshStatus Status { get; init; } + + public AuthSessionId? SessionId { get; init; } + + public bool DidTouch { get; init; } + + public bool IsSuccess => Status == SessionRefreshStatus.Success; + public bool RequiresReauth => Status == SessionRefreshStatus.ReauthRequired; + + private SessionRefreshResult() { } + + public static SessionRefreshResult Success( + AuthSessionId sessionId, + bool didTouch = false) + => new() + { + Status = SessionRefreshStatus.Success, + SessionId = sessionId, + DidTouch = didTouch + }; + + public static SessionRefreshResult ReauthRequired() + => new() + { + Status = SessionRefreshStatus.ReauthRequired + }; + + public static SessionRefreshResult InvalidRequest() + => new() + { + Status = SessionRefreshStatus.InvalidRequest + }; + + public static SessionRefreshResult Failed() + => new() + { + Status = SessionRefreshStatus.Failed + }; + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs new file mode 100644 index 00000000..3be8b529 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionRotationContext.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionRotationContext +{ + public TenantKey Tenant { get; init; } + public AuthSessionId CurrentSessionId { get; init; } + public UserKey UserKey { get; init; } + public DateTimeOffset Now { get; init; } + public required DeviceContext Device { get; init; } + public ClaimsSnapshot? Claims { get; init; } + public required SessionMetadata Metadata { get; init; } = SessionMetadata.Empty; + public required UAuthMode Mode { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs new file mode 100644 index 00000000..5e52414e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionSecurityContext.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionSecurityContext +{ + public required UserKey? UserKey { get; init; } + + public required AuthSessionId SessionId { get; init; } + + public SessionState State { get; init; } + + public SessionChainId? ChainId { get; init; } + + public DeviceId? BoundDeviceId { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs new file mode 100644 index 00000000..3bcda73b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionStoreContext.cs @@ -0,0 +1,43 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Context information required by the session store when +/// creating or rotating sessions. +/// +public sealed class SessionStoreContext +{ + /// + /// The authenticated user identifier. + /// + public required UserKey UserKey { get; init; } + + /// + /// The tenant identifier, if multi-tenancy is enabled. + /// + public TenantKey Tenant { get; init; } + + /// + /// Optional chain identifier. + /// If null, a new chain should be created. + /// + public SessionChainId? ChainId { get; init; } + + /// + /// Indicates whether the session is metadata-only + /// (used in SemiHybrid mode). + /// + public bool IsMetadataOnly { get; init; } + + /// + /// The UTC timestamp when the session was issued. + /// + public DateTimeOffset IssuedAt { get; init; } + + /// + /// Optional device or client identifier. + /// + public required DeviceContext Device { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs new file mode 100644 index 00000000..ff3d61d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionTouchMode.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum SessionTouchMode +{ + /// + /// Touch only if store policy allows (interval, throttling, etc.) + /// + IfNeeded = 0, + + /// + /// Always update session activity, ignoring store heuristics. + /// + Force = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs new file mode 100644 index 00000000..6a493e06 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationContext.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record SessionValidationContext +{ + public TenantKey Tenant { get; init; } + public AuthSessionId SessionId { get; init; } + public DateTimeOffset Now { get; init; } + public required DeviceContext Device { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs new file mode 100644 index 00000000..9443abab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Session/SessionValidationResult.cs @@ -0,0 +1,69 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class SessionValidationResult +{ + public TenantKey Tenant { get; init; } + + public required SessionState State { get; init; } + + public UserKey? UserKey { get; init; } + + public AuthSessionId? SessionId { get; init; } + + public SessionChainId? ChainId { get; init; } + + public SessionRootId? RootId { get; init; } + + public DeviceId? BoundDeviceId { get; init; } + + public DateTimeOffset? AuthenticatedAt { get; init; } + + public ClaimsSnapshot Claims { get; init; } = ClaimsSnapshot.Empty; + + public bool IsValid => State == SessionState.Active; + + private SessionValidationResult() { } + + public static SessionValidationResult Active( + TenantKey tenant, + UserKey? userKey, + AuthSessionId sessionId, + SessionChainId chainId, + SessionRootId rootId, + ClaimsSnapshot claims, + DateTimeOffset authenticatedAt, + DeviceId? boundDeviceId = null) + => new() + { + Tenant = tenant, + State = SessionState.Active, + UserKey = userKey, + SessionId = sessionId, + ChainId = chainId, + RootId = rootId, + Claims = claims, + AuthenticatedAt = authenticatedAt, + BoundDeviceId = boundDeviceId + }; + + public static SessionValidationResult Invalid( + SessionState state, + UserKey? userId = null, + AuthSessionId? sessionId = null, + SessionChainId? chainId = null, + SessionRootId? rootId = null, + DeviceId? boundDeviceId = null) + => new() + { + State = state, + UserKey = userId, + SessionId = sessionId, + ChainId = chainId, + RootId = rootId, + Claims = ClaimsSnapshot.Empty, + BoundDeviceId = boundDeviceId + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs new file mode 100644 index 00000000..459c8d0d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AccessToken.cs @@ -0,0 +1,31 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents an issued access token (JWT or opaque). +/// +public sealed class AccessToken +{ + /// + /// The actual token value sent to the client. + /// + public required string Token { get; init; } + + // TODO: TokenKind enum? + /// + /// Token type: "jwt" or "opaque". + /// Used for diagnostics and middleware behavior. + /// + public TokenType Type { get; init; } + + /// + /// Expiration time of the token. + /// + public required DateTimeOffset ExpiresAt { get; init; } + + /// + /// Optional session id this token is bound to (Hybrid / SemiHybrid). + /// + public string? SessionId { get; init; } + + public string? Scope { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs new file mode 100644 index 00000000..182bd3e0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/AuthTokens.cs @@ -0,0 +1,16 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Represents a set of authentication tokens issued as a result of a successful login. +/// This model is intentionally extensible to support additional token types in the future. +/// +public sealed record AuthTokens +{ + /// + /// The issued access token. + /// Always present when is returned. + /// + public required AccessToken AccessToken { get; init; } + + public RefreshTokenInfo? RefreshToken { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs new file mode 100644 index 00000000..be3a120c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryToken.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record PrimaryToken +{ + public PrimaryTokenKind Kind { get; } + public string Value { get; } + + private PrimaryToken(PrimaryTokenKind kind, string value) + { + Kind = kind; + Value = value; + } + + public static PrimaryToken FromSession(AuthSessionId sessionId) => new(PrimaryTokenKind.Session, sessionId.ToString()); + + public static PrimaryToken FromAccessToken(AccessToken token) => new(PrimaryTokenKind.AccessToken, token.Token); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs new file mode 100644 index 00000000..06cd52af --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/PrimaryTokenKind.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum PrimaryTokenKind +{ + Session = 0, + AccessToken = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs new file mode 100644 index 00000000..6d5648b5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenInfo.cs @@ -0,0 +1,22 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +/// +/// Transport model for refresh token. Returned to client once upon creation. +/// +public sealed class RefreshTokenInfo +{ + /// + /// Plain refresh token value (returned to client once). + /// + public required string Token { get; init; } + + /// + /// Hash of the refresh token to be persisted. + /// + public required string TokenHash { get; init; } + + /// + /// Expiration time. + /// + public required DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs new file mode 100644 index 00000000..d60ea275 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationContext.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenRotationContext +{ + public string RefreshToken { get; init; } = default!; + public DateTimeOffset Now { get; init; } + public required DeviceContext Device { get; init; } + public AuthSessionId? ExpectedSessionId { get; init; } // For Hybrid +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs new file mode 100644 index 00000000..76c63775 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationExecution.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenRotationExecution +{ + public RefreshTokenRotationResult Result { get; init; } = default!; + + // INTERNAL โ€“ flow/orchestrator only + public UserKey? UserKey { get; init; } + public AuthSessionId? SessionId { get; init; } + public SessionChainId? ChainId { get; init; } + public TenantKey Tenant { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs new file mode 100644 index 00000000..af715f12 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenRotationResult.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +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; } + + private RefreshTokenRotationResult() { } + + public static RefreshTokenRotationResult Failed() => new() { IsSuccess = false, ReauthRequired = true }; + + public static RefreshTokenRotationResult Success( + AccessToken accessToken, + RefreshTokenInfo refreshToken) + => new() + { + IsSuccess = true, + AccessToken = accessToken, + RefreshToken = refreshToken + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs new file mode 100644 index 00000000..8ab8977b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/RefreshTokenValidationResult.cs @@ -0,0 +1,63 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record RefreshTokenValidationResult +{ + public bool IsValid { get; init; } + public bool IsReuseDetected { get; init; } + + public string? TokenHash { get; init; } + + public TenantKey Tenant { get; init; } + public UserKey? UserKey { get; init; } + public AuthSessionId? SessionId { get; init; } + public SessionChainId? ChainId { get; init; } + + public DateTimeOffset? ExpiresAt { get; init; } + + + private RefreshTokenValidationResult() { } + + public static RefreshTokenValidationResult Invalid() + => new() + { + IsValid = false, + IsReuseDetected = false + }; + + public static RefreshTokenValidationResult ReuseDetected( + TenantKey tenant, + AuthSessionId? sessionId = null, + string? tokenHash = null, + SessionChainId? chainId = null, + UserKey? userKey = default) + => new() + { + IsValid = false, + IsReuseDetected = true, + Tenant = tenant, + SessionId = sessionId, + TokenHash = tokenHash, + ChainId = chainId, + UserKey = userKey, + }; + + public static RefreshTokenValidationResult Valid( + TenantKey tenant, + UserKey userKey, + AuthSessionId sessionId, + string? tokenHash, + SessionChainId? chainId = null) + => new() + { + IsValid = true, + IsReuseDetected = false, + Tenant = tenant, + UserKey = userKey, + SessionId = sessionId, + ChainId = chainId, + TokenHash = tokenHash + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs new file mode 100644 index 00000000..a50157ce --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenFormat.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +// TODO: It's same as TokenType +// It's not primary token kind, it's about transport format. +public enum TokenFormat +{ + Opaque = 0, + Jwt = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs new file mode 100644 index 00000000..3e2dfb49 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenInvalidReason.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum TokenInvalidReason +{ + Invalid = 0, + Expired = 10, + Revoked = 20, + Malformed = 30, + SignatureInvalid = 40, + AudienceMismatch = 50, + IssuerMismatch = 60, + MissingSubject = 70, + NotImplemented = 80, + Unknown = 100 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs new file mode 100644 index 00000000..fb95004f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenIssuanceContext.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +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 AuthSessionId? SessionId { get; init; } + public SessionChainId? ChainId { get; init; } + public DateTimeOffset IssuedAt { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs new file mode 100644 index 00000000..2796946b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenRefreshContext.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TokenRefreshContext +{ + public TenantKey Tenant { get; init; } + + public string RefreshToken { get; init; } = default!; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs new file mode 100644 index 00000000..da231a01 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenType.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum TokenType +{ + Opaque = 0, + Jwt = 10, + Unknown = 100 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs new file mode 100644 index 00000000..f0247ddf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Token/TokenValidationResult.cs @@ -0,0 +1,67 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed record TokenValidationResult +{ + public bool IsValid { get; init; } + public TokenType Type { get; init; } + public TenantKey? Tenant { get; init; } + public TUserId? UserId { get; init; } + public AuthSessionId? SessionId { get; init; } + public IReadOnlyCollection Claims { get; init; } = Array.Empty(); + public TokenInvalidReason? InvalidReason { get; init; } + public DateTimeOffset? ExpiresAt { get; set; } + + private TokenValidationResult( + bool isValid, + TokenType type, + TenantKey? tenant, + TUserId? userId, + AuthSessionId? sessionId, + IReadOnlyCollection? claims, + TokenInvalidReason? invalidReason, + DateTimeOffset? expiresAt + ) + { + IsValid = isValid; + Tenant = tenant; + UserId = userId; + SessionId = sessionId; + Claims = claims ?? Array.Empty(); + InvalidReason = invalidReason; + ExpiresAt = expiresAt; + } + + public static TokenValidationResult Valid( + TokenType type, + TenantKey tenant, + TUserId userId, + AuthSessionId? sessionId, + IReadOnlyCollection claims, + DateTimeOffset? expiresAt) + => new( + isValid: true, + type, + tenant, + userId, + sessionId, + claims, + invalidReason: null, + expiresAt + ); + + public static TokenValidationResult Invalid(TokenType type, TokenInvalidReason reason) + => new( + isValid: false, + type: type, + tenant: null, + userId: default, + sessionId: null, + claims: null, + invalidReason: reason, + expiresAt: null + ); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs new file mode 100644 index 00000000..d2964274 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/Unit.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public readonly struct Unit +{ + public static readonly Unit Value = new(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs new file mode 100644 index 00000000..2f61fa68 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/AuthUserSnapshot.cs @@ -0,0 +1,27 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +// This is for AuthFlowContext, with minimal data and no db access +/// +/// Represents the minimal authentication state of the current request. +/// This type is request-scoped and contains no domain or persistence data. +/// +/// AuthUserSnapshot answers only the question: +/// "Is there an authenticated user associated with this execution context?" +/// +/// It must not be used for user discovery, lifecycle decisions, +/// or authorization policies. +/// +public sealed class AuthUserSnapshot +{ + public bool IsAuthenticated { get; } + public TUserId? UserId { get; } + + private AuthUserSnapshot(bool isAuthenticated, TUserId? userId) + { + IsAuthenticated = isAuthenticated; + UserId = userId; + } + + public static AuthUserSnapshot Authenticated(TUserId userId) => new(true, userId); + public static AuthUserSnapshot Anonymous() => new(false, default); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs new file mode 100644 index 00000000..a105c336 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserAuthenticationResult.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class UserAuthenticationResult +{ + public bool Succeeded { get; init; } + + public TUserId? UserId { get; init; } + + public ClaimsSnapshot? Claims { get; init; } + + public bool RequiresMfa { get; init; } + + public static UserAuthenticationResult Fail() => new() { Succeeded = false }; + + public static UserAuthenticationResult Success(TUserId userId, ClaimsSnapshot claims, bool requiresMfa = false) + => new() + { + Succeeded = true, + UserId = userId, + Claims = claims, + RequiresMfa = requiresMfa + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs new file mode 100644 index 00000000..5a20021b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserContext.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Contracts; + +public sealed class UserContext +{ + public TUserId? UserId { get; init; } + public IAuthSubject? User { get; init; } + + public bool IsAuthenticated => UserId is not null; + + public static UserContext Anonymous() => new(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs new file mode 100644 index 00000000..8bc8a155 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Contracts/User/UserStatus.cs @@ -0,0 +1,20 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Contracts; + +public enum UserStatus +{ + + Active = 0, + + SelfSuspended = 10, + + Disabled = 20, + Suspended = 30, + + Locked = 40, + RiskHold = 50, + + PendingActivation = 60, + PendingVerification = 70, + + Unknown = 100 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs new file mode 100644 index 00000000..58fc735b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthActions.cs @@ -0,0 +1,132 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Defaults; + +public static class UAuthActions +{ + public static string Create(string resource, string operation, ActionScope scope, string? subResource = null) + { + if (string.IsNullOrWhiteSpace(resource)) + throw new ArgumentException("resource required"); + + if (string.IsNullOrWhiteSpace(operation)) + throw new ArgumentException("operation required"); + + var scopePart = scope.ToString().ToLowerInvariant(); + + return subResource is null + ? $"{resource}.{operation}.{scopePart}" + : $"{resource}.{subResource}.{operation}.{scopePart}"; + } + + public static class Flows + { + public const string Wildcard = "flows.*"; + + public const string LogoutSelf = "flows.logout.self"; + public const string LogoutDeviceSelf = "flows.logoutdevice.self"; + public const string LogoutDeviceAdmin = "flows.logoutdevice.admin"; + public const string LogoutOthersSelf = "flows.logoutothers.self"; + public const string LogoutOthersAdmin = "flows.logoutothers.admin"; + public const string LogoutAllSelf = "flows.logoutall.self"; + public const string LogoutAllAdmin = "flows.logoutall.admin"; + } + + public static class Sessions + { + public const string Wildcard = "sessions.*"; + + public const string GetChainSelf = "sessions.getchain.self"; + public const string GetChainAdmin = "sessions.getchain.admin"; + public const string ListChainsSelf = "sessions.listchains.self"; + public const string ListChainsAdmin = "sessions.listchains.admin"; + public const string RevokeChainSelf = "sessions.revokechain.self"; + public const string RevokeChainAdmin = "sessions.revokechain.admin"; + public const string RevokeAllChainsSelf = "sessions.revokeallchains.self"; + public const string RevokeAllChainsAdmin = "sessions.revokeallchains.admin"; + public const string RevokeOtherChainsSelf = "sessions.revokeotherchains.self"; + public const string RevokeSessionAdmin = "sessions.revoke.admin"; + public const string RevokeRootAdmin = "sessions.revokeroot.admin"; + } + + public static class Users + { + public const string Wildcard = "users.*"; + + public const string QueryAdmin = "users.query.admin"; + public const string CreateAnonymous = "users.create.anonymous"; + public const string CreateAdmin = "users.create.admin"; + public const string DeleteSelf = "users.delete.self"; + public const string DeleteAdmin = "users.delete.admin"; + public const string ChangeStatusSelf = "users.status.change.self"; + public const string ChangeStatusAdmin = "users.status.change.admin"; + } + + public static class UserProfiles + { + public const string Wildcard = "users.profile.*"; + + public const string GetSelf = "users.profile.get.self"; + public const string UpdateSelf = "users.profile.update.self"; + public const string GetAdmin = "users.profile.get.admin"; + public const string UpdateAdmin = "users.profile.update.admin"; + } + + public static class UserIdentifiers + { + public const string Wildcard = "users.identifiers.*"; + + public const string GetSelf = "users.identifiers.get.self"; + public const string GetAdmin = "users.identifiers.get.admin"; + public const string AddSelf = "users.identifiers.add.self"; + public const string AddAdmin = "users.identifiers.add.admin"; + public const string UpdateSelf = "users.identifiers.update.self"; + public const string UpdateAdmin = "users.identifiers.update.admin"; + public const string SetPrimarySelf = "users.identifiers.setprimary.self"; + public const string SetPrimaryAdmin = "users.identifiers.setprimary.admin"; + public const string UnsetPrimarySelf = "users.identifiers.unsetprimary.self"; + public const string UnsetPrimaryAdmin = "users.identifiers.unsetprimary.admin"; + public const string VerifySelf = "users.identifiers.verify.self"; + public const string VerifyAdmin = "users.identifiers.verify.admin"; + public const string DeleteSelf = "users.identifiers.delete.self"; + public const string DeleteAdmin = "users.identifiers.delete.admin"; + } + + public static class Credentials + { + public const string Wildcard = "credentials.*"; + + public const string ListSelf = "credentials.list.self"; + public const string ListAdmin = "credentials.list.admin"; + public const string AddSelf = "credentials.add.self"; + public const string AddAdmin = "credentials.add.admin"; + public const string ChangeSelf = "credentials.change.self"; + public const string ChangeAdmin = "credentials.change.admin"; + public const string RevokeSelf = "credentials.revoke.self"; + public const string RevokeAdmin = "credentials.revoke.admin"; + public const string ActivateSelf = "credentials.activate.self"; + public const string BeginResetAnonymous = "credentials.beginreset.anonymous"; + public const string BeginResetAdmin = "credentials.beginreset.admin"; + public const string CompleteResetAnonymous = "credentials.completereset.anonymous"; + public const string CompleteResetAdmin = "credentials.completereset.admin"; + public const string DeleteAdmin = "credentials.delete.admin"; + } + + public static class Authorization + { + public const string Wildcard = "authorization.*"; + + public static class Roles + { + public const string GetSelf = "authorization.roles.get.self"; + public const string GetAdmin = "authorization.roles.get.admin"; + public const string AssignAdmin = "authorization.roles.assign.admin"; + public const string RemoveAdmin = "authorization.roles.remove.admin"; + public const string CreateAdmin = "authorization.roles.create.admin"; + public const string RenameAdmin = "authorization.roles.rename.admin"; + public const string DeleteAdmin = "authorization.roles.delete.admin"; + public const string SetPermissionsAdmin = "authorization.roles.permissions.admin"; + public const string QueryAdmin = "authorization.roles.query.admin"; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs new file mode 100644 index 00000000..c6761ad6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Defaults/UAuthConstants.cs @@ -0,0 +1,54 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Defaults; + +public static class UAuthConstants +{ + public static class SchemeDefaults + { + public const string GlobalScheme = "UltimateAuth"; + } + + public static class Access + { + public const string Permissions = "permissions"; + } + + public static class Claims + { + public const string Tenant = "uauth:tenant"; + public const string Permission = "uauth:permission"; + } + + public static class HttpItems + { + public const string SessionContext = "__UAuth.SessionContext"; + public const string SessionValidationResult = "__UAuth.SessionValidationResult"; + public const string TenantContextKey = "__UAuthTenant"; + public const string UserContextKey = "__UAuthUser"; + } + + public static class Form + { + public const string ReturnUrl = "return_url"; + public const string Device = "__uauth_device"; + public const string ClientProfile = "__uauth_client_profile"; + } + + public static class Query + { + public const string ReturnUrl = "return_url"; + public const string Hub = "hub"; + } + + public static class Headers + { + public const string ClientProfile = "X-UAuth-ClientProfile"; + public const string DeviceId = "X-UDID"; + public const string Refresh = "X-UAuth-Refresh"; + public const string AuthState = "X-UAuth-AuthState"; + } + + public static class Routes + { + public const string LoginRedirect = "/__uauth/login-redirect"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifact.cs new file mode 100644 index 00000000..1210c167 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifact.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public abstract class AuthArtifact +{ + protected AuthArtifact(AuthArtifactType type, DateTimeOffset expiresAt) + { + Type = type; + ExpiresAt = expiresAt; + } + + public AuthArtifactType Type { get; } + + public DateTimeOffset ExpiresAt { get; internal set; } + + public int AttemptCount { get; private set; } + public bool IsCompleted { get; private set; } + + public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt; + + public void RegisterAttempt() + { + AttemptCount++; + } + + public void MarkCompleted() + { + IsCompleted = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs new file mode 100644 index 00000000..85157ad7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/AuthArtifactType.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthArtifactType +{ + PkceAuthorizationCode, + HubFlow, + LoginPreview, + HubLogin, + MfaChallenge, + PasswordReset, + MagicLink, + OAuthState, + Custom = 1000 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs new file mode 100644 index 00000000..78d69a80 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Auth/LoginPreviewArtifact.cs @@ -0,0 +1,53 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class LoginPreviewArtifact : AuthArtifact +{ + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public CredentialType Factor { get; } + public string DeviceId { get; } + public string Identifier { get; } + public UAuthClientProfile ClientProfile { get; } + public string Fingerprint { get; } + + public LoginPreviewArtifact( + TenantKey tenant, + UserKey userKey, + CredentialType factor, + string deviceId, + string identifier, + UAuthClientProfile clientProfile, + string fingerprint, + DateTimeOffset expiresAt) + : base(AuthArtifactType.LoginPreview, expiresAt) + { + Tenant = tenant; + UserKey = userKey; + Factor = factor; + DeviceId = deviceId; + Identifier = identifier; + ClientProfile = clientProfile; + Fingerprint = fingerprint; + } + + public bool Matches( + TenantKey tenant, + UserKey userKey, + CredentialType factor, + string deviceId, + string identifier, + UAuthClientProfile clientProfile, + string fingerprint) + { + return Tenant == tenant + && UserKey == userKey + && Factor == factor + && string.Equals(DeviceId, deviceId, StringComparison.Ordinal) + && string.Equals(Identifier, identifier, StringComparison.Ordinal) + && ClientProfile == clientProfile + && string.Equals(Fingerprint, fingerprint, StringComparison.Ordinal); + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs new file mode 100644 index 00000000..905d54df --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/AuthFlowType.cs @@ -0,0 +1,30 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthFlowType +{ + Login, + Reauthentication, + + Logout, + RefreshSession, + ValidateSession, + + IssueToken, + RefreshToken, + IntrospectToken, + RevokeToken, + + QuerySession, + RevokeSession, + + UserInfo, + PermissionQuery, + + UserManagement, + UserProfileManagement, + UserIdentifierManagement, + CredentialManagement, + AuthorizationManagement, + + ApiAccess +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs new file mode 100644 index 00000000..4c00e82c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceContext.cs @@ -0,0 +1,70 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(DeviceContextJsonConverter))] +public sealed class DeviceContext +{ + public DeviceId? DeviceId { get; init; } + public string? DeviceType { get; init; } + public string? OperatingSystem { get; init; } + public string? Platform { get; init; } + public string? Browser { get; init; } + public string? IpAddress { get; init; } + + public bool HasDeviceId => DeviceId is not null; + + private DeviceContext( + DeviceId? deviceId, + string? deviceType, + string? platform, + string? operatingSystem, + string? browser, + string? ipAddress) + { + DeviceId = deviceId; + DeviceType = deviceType; + Platform = platform; + OperatingSystem = operatingSystem; + Browser = browser; + IpAddress = ipAddress; + } + + public static DeviceContext Anonymous() + => new( + deviceId: null, + deviceType: null, + platform: null, + operatingSystem: null, + browser: null, + ipAddress: null); + + public static DeviceContext Create( + DeviceId deviceId, + string? deviceType = null, + string? platform = null, + string? operatingSystem = null, + string? browser = null, + string? ipAddress = null) + { + return new DeviceContext( + deviceId, + Normalize(deviceType), + Normalize(platform), + Normalize(operatingSystem), + Normalize(browser), + Normalize(ipAddress)); + } + + private static string? Normalize(string? value) + => string.IsNullOrWhiteSpace(value) + ? null + : value.Trim().ToLowerInvariant(); + + // DeviceInfo is a transport object. + // AuthFlowContextFactory changes it to a useable DeviceContext + // DeviceContext doesn't have fields like IsTrusted etc. It's authority layer's responsibility. + // Geo and Fingerprint will be added here. + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs new file mode 100644 index 00000000..18800f17 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/DeviceId.cs @@ -0,0 +1,72 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Security; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(DeviceIdJsonConverter))] +public readonly record struct DeviceId +{ + public const int MinLength = 16; + public const int MaxLength = 256; + + private readonly string _value; + + public string Value => _value; + + private DeviceId(string value) + { + _value = value; + } + + public static DeviceId Create(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + throw new SecurityException("DeviceId is required."); + + raw = raw.Trim(); + + if (raw == "undefined" || raw == "null") + throw new SecurityException("Invalid DeviceId."); + + if (raw.Length < MinLength) + throw new SecurityException("DeviceId entropy is too low."); + + if (raw.Length > MaxLength) + throw new SecurityException("DeviceId is too long."); + + return new DeviceId(raw); + } + + public static bool TryCreate(string? raw, out DeviceId deviceId) + { + deviceId = default; + + if (string.IsNullOrWhiteSpace(raw)) + return false; + + raw = raw.Trim(); + + if (raw == "undefined" || raw == "null") + return false; + + if (raw.Length < MinLength || raw.Length > MaxLength) + return false; + + deviceId = new DeviceId(raw); + return true; + } + + public static DeviceId CreateFromBytes(ReadOnlySpan bytes) + { + if (bytes.Length < 32) + throw new SecurityException("DeviceId entropy is too low."); + + var raw = Convert.ToBase64String(bytes); + return new DeviceId(raw); + } + + public override string ToString() => _value; + + public static explicit operator string(DeviceId id) => id._value; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs new file mode 100644 index 00000000..88d61eb3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Device/UserAgentInfo.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class UserAgentInfo +{ + public string? DeviceType { get; init; } + public string? Platform { get; init; } + public string? OperatingSystem { get; init; } + public string? Browser { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs new file mode 100644 index 00000000..458ca78f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubCredentials.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class HubCredentials +{ + public string AuthorizationCode { get; init; } = default!; + public string CodeVerifier { get; init; } = default!; + public UAuthClientProfile ClientProfile { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs new file mode 100644 index 00000000..c185ab21 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubErrorCode.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum HubErrorCode +{ + None = 0, + InvalidCredentials, + LockedOut, + RequiresMfa, + Unknown +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs new file mode 100644 index 00000000..e350d863 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowArtifact.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class HubFlowArtifact : AuthArtifact +{ + public HubSessionId HubSessionId { get; } + public HubFlowType FlowType { get; } + + public UAuthClientProfile ClientProfile { get; } + public TenantKey Tenant { get; } + public DeviceContext Device { get; } + public string? ReturnUrl { get; } + + public HubFlowPayload Payload { get; } + + public HubErrorCode? Error { get; private set; } + + public HubFlowArtifact( + HubSessionId hubSessionId, + HubFlowType flowType, + UAuthClientProfile clientProfile, + TenantKey tenant, + DeviceContext device, + string? returnUrl, + HubFlowPayload payload, + DateTimeOffset expiresAt) + : base(AuthArtifactType.HubFlow, expiresAt) + { + HubSessionId = hubSessionId; + FlowType = flowType; + ClientProfile = clientProfile; + Tenant = tenant; + Device = device; + ReturnUrl = returnUrl; + Payload = payload; + } + + public void SetError(HubErrorCode error) + { + Error = error; + RegisterAttempt(); + } + + public void ClearError() + { + Error = null; + RegisterAttempt(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs new file mode 100644 index 00000000..47c2c78f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowPayload.cs @@ -0,0 +1,35 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class HubFlowPayload +{ + private readonly Dictionary _items = new(); + + public IReadOnlyDictionary Items => _items; + + public void Set(string key, T value) => _items[key] = value; + + public bool TryGet(string key, out T? value) + { + if (_items.TryGetValue(key, out var raw) && raw is T t) + { + value = t; + return true; + } + + value = default; + return false; + } + + public T GetRequired(string key) + { + if (TryGet(key, out var value) && value is not null) + return value; + + throw new InvalidOperationException($"Payload key '{key}' is missing or invalid."); + } + + public T? GetOptional(string key) + { + return TryGet(key, out var value) ? value : default; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs new file mode 100644 index 00000000..691d7a57 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowState.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class HubFlowState +{ + public HubSessionId HubSessionId { get; init; } + public HubFlowType FlowType { get; init; } + public UAuthClientProfile ClientProfile { get; init; } + public string? ReturnUrl { get; init; } + public HubErrorCode? Error { get; init; } + public int AttemptCount { get; init; } + + public bool IsActive { get; init; } + public bool IsExpired { get; init; } + public bool IsCompleted { get; init; } + public bool Exists { get; init; } + + public HubFlowState ClearError() + { + return new HubFlowState + { + HubSessionId = HubSessionId, + FlowType = FlowType, + ClientProfile = ClientProfile, + ReturnUrl = ReturnUrl, + Error = null, + AttemptCount = AttemptCount, + IsActive = IsActive, + IsExpired = IsExpired, + IsCompleted = IsCompleted, + Exists = Exists + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs new file mode 100644 index 00000000..3d3980c7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubFlowType.cs @@ -0,0 +1,13 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum HubFlowType +{ + None = 0, + + Login = 1, + Mfa = 2, + Reauthentication = 3, + Consent = 4, + + Custom = 1000 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs new file mode 100644 index 00000000..ace40a14 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Hub/HubSessionId.cs @@ -0,0 +1,38 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +// TODO: Bind id with IP and UA +public readonly record struct HubSessionId +{ + public string Value { get; } + + private HubSessionId(string value) + { + Value = value; + } + + public static HubSessionId New() => new(Guid.NewGuid().ToString("N")); + + public static HubSessionId Parse(string value) + { + if (!TryParse(value, out var id)) + throw new FormatException("Invalid HubSessionId."); + + return id; + } + + public static bool TryParse(string? value, out HubSessionId sessionId) + { + sessionId = default; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (!Guid.TryParseExact(value, "N", out _)) + return false; + + sessionId = new HubSessionId(value); + return true; + } + + public override string ToString() => Value; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs new file mode 100644 index 00000000..c6ceb415 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Pkce/HubLoginArtifact.cs @@ -0,0 +1,17 @@ +๏ปฟ//namespace CodeBeam.UltimateAuth.Core.Domain; + +//public sealed class HubLoginArtifact : AuthArtifact +//{ +// public string AuthorizationCode { get; } +// public string CodeVerifier { get; } + +// public HubLoginArtifact( +// string authorizationCode, +// string codeVerifier, +// DateTimeOffset expiresAt) +// : base(AuthArtifactType.HubLogin, expiresAt) +// { +// AuthorizationCode = authorizationCode; +// CodeVerifier = codeVerifier; +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs new file mode 100644 index 00000000..b0b148ed --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/AuthFailureReason.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthFailureReason +{ + InvalidCredentials, + LockedOut, + RequiresMfa, + SessionExpired, + SessionRevoked, + TenantDisabled, + Unauthorized, + ReauthenticationRequired, + Unknown +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs new file mode 100644 index 00000000..399c1cdd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ClaimsSnapshotBuilder.cs @@ -0,0 +1,38 @@ +๏ปฟusing System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class ClaimsSnapshotBuilder +{ + private readonly Dictionary> _claims = new(StringComparer.Ordinal); + + public ClaimsSnapshotBuilder Add(string type, string value) + { + if (!_claims.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + _claims[type] = set; + } + + set.Add(value); + return this; + } + + public ClaimsSnapshotBuilder AddMany(string type, IEnumerable values) + { + foreach (var v in values) + Add(type, v); + + return this; + } + + public ClaimsSnapshotBuilder AddRole(string role) => Add(ClaimTypes.Role, role); + + public ClaimsSnapshotBuilder AddPermission(string permission) => Add("uauth:permission", permission); + + public ClaimsSnapshot Build() + { + var frozen = _claims.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal); + return new ClaimsSnapshot(frozen); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs new file mode 100644 index 00000000..601b18f8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/GrantKind.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum GrantKind +{ + Session, + AccessToken, + RefreshToken +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs new file mode 100644 index 00000000..9e12f09d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/PrimaryGrantKind.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum PrimaryGrantKind +{ + Stateful, + Stateless +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs new file mode 100644 index 00000000..44262fe4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Principals/ReauthBehavior.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum ReauthBehavior +{ + Redirect, + None, + RaiseEvent +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs new file mode 100644 index 00000000..cd0c6c31 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityScope.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum AuthenticationSecurityScope +{ + Account = 0, + Factor = 1 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs new file mode 100644 index 00000000..d30489d9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/AuthenticationSecurityState.cs @@ -0,0 +1,501 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Security; + +// TODO: Do not store reset token hash in db. +public sealed class AuthenticationSecurityState : ITenantEntity, IVersionedEntity, IEntitySnapshot +{ + public Guid Id { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public AuthenticationSecurityScope Scope { get; } + public CredentialType? CredentialType { get; } + + public int FailedAttempts { get; } + public DateTimeOffset? LastFailedAt { get; } + public DateTimeOffset? LockedUntil { get; } + public bool RequiresReauthentication { get; } + + public DateTimeOffset? ResetRequestedAt { get; } + public DateTimeOffset? ResetExpiresAt { get; } + public DateTimeOffset? ResetConsumedAt { get; } + public string? ResetTokenHash { get; } + public int ResetAttempts { get; } + + public long SecurityVersion { get; } + + public bool IsLocked(DateTimeOffset now) => LockedUntil.HasValue && LockedUntil > now; + public bool HasResetRequest => ResetRequestedAt is not null; + + + long IVersionedEntity.Version + { + get => SecurityVersion; + set => throw new NotSupportedException("AuthenticationSecurityState uses SecurityVersion."); + } + + private AuthenticationSecurityState( + Guid id, + TenantKey tenant, + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + int failedAttempts, + DateTimeOffset? lastFailedAt, + DateTimeOffset? lockedUntil, + bool requiresReauthentication, + DateTimeOffset? resetRequestedAt, + DateTimeOffset? resetExpiresAt, + DateTimeOffset? resetConsumedAt, + string? resetTokenHash, + int resetAttempts, + long securityVersion) + { + if (id == Guid.Empty) + throw new UAuthValidationException("security_state_id_required"); + + if (scope == AuthenticationSecurityScope.Account && credentialType is not null) + throw new UAuthValidationException("account_scope_must_not_have_credential_type"); + + if (scope == AuthenticationSecurityScope.Factor && credentialType is null) + throw new UAuthValidationException("factor_scope_requires_credential_type"); + + Id = id; + Tenant = tenant; + UserKey = userKey; + Scope = scope; + CredentialType = credentialType; + FailedAttempts = failedAttempts < 0 ? 0 : failedAttempts; + LastFailedAt = lastFailedAt; + LockedUntil = lockedUntil; + RequiresReauthentication = requiresReauthentication; + ResetRequestedAt = resetRequestedAt; + ResetExpiresAt = resetExpiresAt; + ResetConsumedAt = resetConsumedAt; + ResetTokenHash = resetTokenHash; + ResetAttempts = resetAttempts; + SecurityVersion = securityVersion < 0 ? 0 : securityVersion; + } + + public static AuthenticationSecurityState CreateAccount(TenantKey tenant, UserKey userKey, Guid? id = null) + => new( + id ?? Guid.NewGuid(), + tenant, + userKey, + AuthenticationSecurityScope.Account, + credentialType: null, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: false, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: null, + resetTokenHash: null, + resetAttempts: 0, + securityVersion: 0); + + public static AuthenticationSecurityState CreateFactor(TenantKey tenant, UserKey userKey, CredentialType type, Guid? id = null) + => new( + id ?? Guid.NewGuid(), + tenant, + userKey, + AuthenticationSecurityScope.Factor, + credentialType: type, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + requiresReauthentication: false, + resetRequestedAt: null, + resetExpiresAt: null, + resetConsumedAt: null, + resetTokenHash: null, + resetAttempts: 0, + securityVersion: 0); + + /// + /// Resets failures if the last failure is outside the given window. + /// Keeps lock and reauth flags untouched by default. + /// + public AuthenticationSecurityState ResetFailuresIfWindowExpired(DateTimeOffset now, TimeSpan window) + { + if (window <= TimeSpan.Zero) + return this; + + if (LastFailedAt is not DateTimeOffset last) + return this; + + if (now - last <= window) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: LockedUntil, + requiresReauthentication: RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + /// + /// Registers a failed authentication attempt. Optionally locks until now + duration when threshold reached. + /// If already locked, may extend lock depending on extendLock. + /// + public AuthenticationSecurityState RegisterFailure(DateTimeOffset now, int threshold, TimeSpan lockoutDuration, bool extendLock = true) + { + if (threshold < 0) + throw new UAuthValidationException(nameof(threshold)); + + var effectiveFailedAttempts = FailedAttempts; + var effectiveLockedUntil = LockedUntil; + + if (effectiveLockedUntil.HasValue && now >= effectiveLockedUntil.Value) + { + effectiveFailedAttempts = 0; + effectiveLockedUntil = null; + } + + var nextCount = effectiveFailedAttempts + 1; + + DateTimeOffset? nextLockedUntil = effectiveLockedUntil; + + if (threshold > 0 && nextCount >= threshold) + { + var candidate = now.Add(lockoutDuration); + + if (nextLockedUntil is null) + nextLockedUntil = candidate; + else if (extendLock && candidate > nextLockedUntil) + nextLockedUntil = candidate; + } + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: nextCount, + lastFailedAt: now, + lockedUntil: nextLockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + /// + /// Registers a successful authentication: clears failures and lock. + /// + public AuthenticationSecurityState RegisterSuccess() + => new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + + /// + /// Admin/system unlock: clears lock and failures. + /// + public AuthenticationSecurityState Unlock() + => new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + failedAttempts: 0, + lastFailedAt: null, + lockedUntil: null, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + + public AuthenticationSecurityState LockUntil(DateTimeOffset until, bool overwriteIfShorter = false) + { + DateTimeOffset? next = LockedUntil; + + if (next is null) + next = until; + else if (overwriteIfShorter || until > next) + next = until; + + if (next == LockedUntil) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + lockedUntil: next, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + SecurityVersion + 1); + } + + public AuthenticationSecurityState RequireReauthentication() + { + if (RequiresReauthentication) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + requiresReauthentication: true, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ClearReauthentication() + { + if (!RequiresReauthentication) + return this; + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + requiresReauthentication: false, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public bool HasActiveReset(DateTimeOffset now) + { + if (ResetRequestedAt is null) + return false; + + if (ResetConsumedAt is not null) + return false; + + if (ResetExpiresAt is not null && ResetExpiresAt <= now) + return false; + + return true; + } + + public bool IsResetExpired(DateTimeOffset now) + { + return ResetExpiresAt is not null && ResetExpiresAt <= now; + } + + public AuthenticationSecurityState BeginReset(string tokenHash, DateTimeOffset now, TimeSpan validity) + { + if (string.IsNullOrWhiteSpace(tokenHash)) + throw new UAuthValidationException("reset_token_required"); + + if (HasActiveReset(now)) + throw new UAuthConflictException("reset_already_active"); + + var expires = now.Add(validity); + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + resetRequestedAt: now, + resetExpiresAt: expires, + resetConsumedAt: null, + resetTokenHash: tokenHash, + resetAttempts: 0, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState RegisterResetFailure(DateTimeOffset now, int maxAttempts) + { + if (IsResetExpired(now)) + return ClearReset(); + + var next = ResetAttempts + 1; + + if (next >= maxAttempts) + { + return ClearReset(); + } + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + ResetConsumedAt, + ResetTokenHash, + next, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ConsumeReset(DateTimeOffset now) + { + if (ResetRequestedAt is null) + throw new UAuthConflictException("reset_not_requested"); + + if (ResetConsumedAt is not null) + throw new UAuthConflictException("reset_already_used"); + + if (IsResetExpired(now)) + throw new UAuthConflictException("reset_expired"); + + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + ResetRequestedAt, + ResetExpiresAt, + now, + null, + ResetAttempts, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState ClearReset() + { + return new AuthenticationSecurityState( + Id, + Tenant, + UserKey, + Scope, + CredentialType, + FailedAttempts, + LastFailedAt, + LockedUntil, + RequiresReauthentication, + null, + null, + null, + null, + 0, + securityVersion: SecurityVersion + 1); + } + + public AuthenticationSecurityState Snapshot() + { + return new AuthenticationSecurityState( + id: Id, + tenant: Tenant, + userKey: UserKey, + scope: Scope, + credentialType: CredentialType, + failedAttempts: FailedAttempts, + lastFailedAt: LastFailedAt, + lockedUntil: LockedUntil, + requiresReauthentication: RequiresReauthentication, + resetRequestedAt: ResetRequestedAt, + resetExpiresAt: ResetExpiresAt, + resetConsumedAt: ResetConsumedAt, + resetTokenHash: ResetTokenHash, + resetAttempts: ResetAttempts, + securityVersion: SecurityVersion + ); + } + + public static AuthenticationSecurityState FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + int failedAttempts, + DateTimeOffset? lastFailedAt, + DateTimeOffset? lockedUntil, + bool requiresReauthentication, + DateTimeOffset? resetRequestedAt, + DateTimeOffset? resetExpiresAt, + DateTimeOffset? resetConsumedAt, + string? resetTokenHash, + int resetAttempts, + long securityVersion) + { + return new AuthenticationSecurityState( + id, + tenant, + userKey, + scope, + credentialType, + failedAttempts, + lastFailedAt, + lockedUntil, + requiresReauthentication, + resetRequestedAt, + resetExpiresAt, + resetConsumedAt, + resetTokenHash, + resetAttempts, + securityVersion); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs new file mode 100644 index 00000000..35226f1b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/CredentialType.cs @@ -0,0 +1,23 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum CredentialType +{ + Password = 0, + + // Possession / OTP based + OneTimeCode = 10, + EmailOtp = 11, + SmsOtp = 12, + + Totp = 30, + + // Modern + Passkey = 40, + + // Machine / system + Certificate = 50, + ApiKey = 60, + + // External / Federated // TODO: Add Microsoft, Google, GitHub etc. + External = 70 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs new file mode 100644 index 00000000..50d23e2c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Security/ResetCodeType.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum ResetCodeType +{ + Token = 0, + Code = 10 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs new file mode 100644 index 00000000..4bc6988f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/AuthSessionId.cs @@ -0,0 +1,63 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +// AuthSessionId is a opaque token, because it's more sensitive data. SessionChainId and SessionRootId are Guid. +[JsonConverter(typeof(AuthSessionIdJsonConverter))] +public readonly record struct AuthSessionId : IParsable +{ + public string Value { get; } + + 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 AuthSessionId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid AuthSessionId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out AuthSessionId result) + { + if (IsValid(s)) + { + result = new AuthSessionId(s!); + return true; + } + + result = default; + return false; + } + + private static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (value.Length < 32) + return false; + + return true; + } + + public override string ToString() => Value; + + public static implicit operator string(AuthSessionId id) => id.Value; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs new file mode 100644 index 00000000..99ff48b2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/ClaimsSnapshot.cs @@ -0,0 +1,182 @@ +๏ปฟusing System.Security.Claims; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +/// +/// Represents a deterministic, immutable collection of authorization claims. +/// +/// This object contains only security-related claims such as: +/// - Roles +/// - Permissions +/// - Policy markers. +/// +/// It must not contain profile data, display data or identity metadata. +/// +public sealed class ClaimsSnapshot +{ + private readonly IReadOnlyDictionary> _claims; + public IReadOnlyDictionary> Claims => _claims; + + [JsonConstructor] + public ClaimsSnapshot(IReadOnlyDictionary> claims) + { + _claims = claims; + } + + public static ClaimsSnapshot Empty { get; } = new(new Dictionary>()); + + public string? Get(string type) => _claims.TryGetValue(type, out var values) ? values.FirstOrDefault() : null; + public bool TryGet(string type, out string value) + { + value = null!; + + if (!Claims.TryGetValue(type, out var values)) + return false; + + var first = values.FirstOrDefault(); + if (first is null) + return false; + + value = first; + return true; + } + public IReadOnlyCollection GetAll(string type) => _claims.TryGetValue(type, out var values) ? values : Array.Empty(); + + public bool Has(string type) => _claims.ContainsKey(type); + public bool HasValue(string type, string value) => _claims.TryGetValue(type, out var values) && values.Contains(value); + + public IReadOnlyCollection Roles => GetAll(ClaimTypes.Role); + public IReadOnlyCollection Permissions => GetAll("uauth:permission"); + + public bool IsInRole(string role) => HasValue(ClaimTypes.Role, role); + public bool HasPermission(string permission) => HasValue("uauth:permission", permission); + + /// + /// Flattens claims by taking the first value of each claim. + /// Useful for logging, diagnostics, or legacy consumers. + /// + public IReadOnlyDictionary AsDictionary() + { + var dict = new Dictionary(StringComparer.Ordinal); + + foreach (var (type, values) in Claims) + { + var first = values.FirstOrDefault(); + if (first is not null) + dict[type] = first; + } + + return dict; + } + + public override bool Equals(object? obj) + { + if (obj is not ClaimsSnapshot other) + return false; + + if (Claims.Count != other.Claims.Count) + return false; + + foreach (var (type, values) in Claims) + { + if (!other.Claims.TryGetValue(type, out var otherValues)) + return false; + + if (values.Count != otherValues.Count) + return false; + + if (!values.All(v => otherValues.Contains(v))) + return false; + } + + return true; + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + + foreach (var (type, values) in Claims.OrderBy(x => x.Key)) + { + hash = hash * 23 + type.GetHashCode(); + + foreach (var value in values.OrderBy(v => v)) + { + hash = hash * 23 + value.GetHashCode(); + } + } + + return hash; + } + } + + public static ClaimsSnapshot From(params (string Type, string Value)[] claims) + { + var dict = new Dictionary>(StringComparer.Ordinal); + + foreach (var (type, value) in claims) + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } + + set.Add(value); + } + + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } + + public ClaimsSnapshot With(params (string Type, string Value)[] claims) + { + if (claims.Length == 0) + return this; + + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); + + foreach (var (type, value) in claims) + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } + + set.Add(value); + } + + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } + + public ClaimsSnapshot Merge(ClaimsSnapshot other) + { + if (other is null || other.Claims.Count == 0) + return this; + + if (Claims.Count == 0) + return other; + + var dict = Claims.ToDictionary(kv => kv.Key, kv => new HashSet(kv.Value, StringComparer.Ordinal), StringComparer.Ordinal); + + foreach (var (type, values) in other.Claims) + { + if (!dict.TryGetValue(type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[type] = set; + } + + foreach (var value in values) + set.Add(value); + } + + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } + + public static ClaimsSnapshotBuilder Create() => new ClaimsSnapshotBuilder(); + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs new file mode 100644 index 00000000..27229e1f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/RefreshOutcome.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum RefreshOutcome +{ + Success, // minimal transport + NoOp, + Touched, + Rotated, + ReauthRequired +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs new file mode 100644 index 00000000..cd104b31 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainId.cs @@ -0,0 +1,58 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(SessionChainIdJsonConverter))] +public readonly record struct SessionChainId(Guid Value) : IParsable +{ + public static SessionChainId New() => new(Guid.NewGuid()); + + /// + /// Indicates that the chain must be assigned by the store. + /// + public static readonly SessionChainId Unassigned = new(Guid.Empty); + + public bool IsUnassigned => Value == Guid.Empty; + + public static SessionChainId From(Guid value) + => value == Guid.Empty + ? throw new ArgumentException("ChainId cannot be empty.", nameof(value)) + : new SessionChainId(value); + + public static bool TryCreate(string raw, out SessionChainId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) + { + id = new SessionChainId(guid); + return true; + } + + id = default; + return false; + } + + public static SessionChainId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid SessionChainId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out SessionChainId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new SessionChainId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString("N"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs new file mode 100644 index 00000000..dde7b81d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionChainState.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum SessionChainState +{ + Active = 0, + Passive = 10, + Revoked = 20 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs new file mode 100644 index 00000000..899c3b03 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionMetadata.cs @@ -0,0 +1,41 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +/// +/// Represents additional metadata attached to an authentication session. +/// This information is application-defined and commonly used for analytics, +/// UI adaptation, multi-tenant context, and CSRF/session-related security data. +/// +public sealed class SessionMetadata +{ + /// + /// Represents an empty or uninitialized session metadata instance. + /// + /// Use this field to represent a default or non-existent session when no metadata is + /// available. This instance contains default values for all properties and can be used for comparison or as a + /// placeholder. + public static readonly SessionMetadata Empty = new SessionMetadata(); + + /// + /// Gets the version of the client application that created the session. + /// Useful for enforcing upgrade policies or troubleshooting version-related issues. + /// + public string? AppVersion { get; init; } + + /// + /// Gets the locale or culture identifier associated with the session, + /// such as en-US, tr-TR, or fr-FR. + /// + public string? Locale { get; init; } + + /// + /// Gets a Cross-Site Request Forgery token or other session-scoped secret + /// used for request integrity validation in web applications. + /// + public string? CsrfToken { get; init; } + + /// + /// Gets a dictionary for storing arbitrary application-defined metadata. + /// Allows extensions without modifying the core authentication model. + /// + public Dictionary? Custom { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs new file mode 100644 index 00000000..16f23a04 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRefreshStatus.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public enum SessionRefreshStatus +{ + Success = 0, + ReauthRequired = 10, + InvalidRequest = 20, + Failed = 30 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs new file mode 100644 index 00000000..20e0b713 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionRootId.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(SessionRootIdJsonConverter))] +public readonly record struct SessionRootId(Guid Value) : IParsable +{ + public static SessionRootId New() => new(Guid.NewGuid()); + + public static SessionRootId From(Guid value) + => value == Guid.Empty + ? throw new ArgumentException("SessionRootId cannot be empty.", nameof(value)) + : new SessionRootId(value); + + public static bool TryCreate(string raw, out SessionRootId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) + { + id = new SessionRootId(guid); + return true; + } + + id = default; + return false; + } + + public static SessionRootId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid SessionRootId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out SessionRootId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new SessionRootId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString("N"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs new file mode 100644 index 00000000..26dccd7c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/SessionState.cs @@ -0,0 +1,17 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +/// +/// Represents the effective runtime state of an authentication session. +/// Evaluated based on expiration rules, revocation status, and security version checks. +/// +public enum SessionState +{ + Active = 0, + Expired = 10, + Revoked = 20, + NotFound = 30, + Invalid = 40, + SecurityMismatch = 50, + DeviceMismatch = 60, + Unsupported = 100 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs new file mode 100644 index 00000000..b9351524 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSession.cs @@ -0,0 +1,162 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class UAuthSession : IVersionedEntity +{ + public AuthSessionId SessionId { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public SessionChainId ChainId { get; } + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset ExpiresAt { get; } + public DateTimeOffset? RevokedAt { get; } + public long SecurityVersionAtCreation { get; } + public DeviceContext Device { get; } // For snapshot,main value is chain's device. + public ClaimsSnapshot Claims { get; } + public SessionMetadata Metadata { get; } + public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; + + private UAuthSession( + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceContext device, + ClaimsSnapshot claims, + SessionMetadata metadata, + long version) + { + SessionId = sessionId; + Tenant = tenant; + UserKey = userKey; + ChainId = chainId; + CreatedAt = createdAt; + ExpiresAt = expiresAt; + RevokedAt = revokedAt; + SecurityVersionAtCreation = securityVersionAtCreation; + Device = device; + Claims = claims; + Metadata = metadata; + Version = version; + } + + public static UAuthSession Create( + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset now, + DateTimeOffset expiresAt, + long securityVersion, + DeviceContext device, + ClaimsSnapshot? claims, + SessionMetadata metadata) + { + return new( + sessionId, + tenant, + userKey, + chainId, + createdAt: now, + expiresAt: expiresAt, + revokedAt: null, + securityVersionAtCreation: securityVersion, + device: device, + claims: claims ?? ClaimsSnapshot.Empty, + metadata: metadata, + version: 0 + ); + } + + public UAuthSession Revoke(DateTimeOffset at) + { + if (IsRevoked) + return this; + + return new UAuthSession( + SessionId, + Tenant, + UserKey, + ChainId, + CreatedAt, + ExpiresAt, + at, + SecurityVersionAtCreation, + Device, + Claims, + Metadata, + Version + 1 + ); + } + + internal static UAuthSession FromProjection( + AuthSessionId sessionId, + TenantKey tenant, + UserKey userKey, + SessionChainId chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt, + DateTimeOffset? revokedAt, + long securityVersionAtCreation, + DeviceContext device, + ClaimsSnapshot claims, + SessionMetadata metadata, + long version) + { + return new UAuthSession( + sessionId, + tenant, + userKey, + chainId, + createdAt, + expiresAt, + revokedAt, + securityVersionAtCreation, + device, + claims, + metadata, + version + ); + } + + public SessionState GetState(DateTimeOffset at) + { + if (IsRevoked) + return SessionState.Revoked; + + if (at >= ExpiresAt) + return SessionState.Expired; + + return SessionState.Active; + } + + public UAuthSession WithChain(SessionChainId chainId) + { + if (!ChainId.IsUnassigned) + throw new UAuthConflictException("Chain already assigned."); + + return new UAuthSession( + sessionId: SessionId, + tenant: Tenant, + userKey: UserKey, + chainId: chainId, + createdAt: CreatedAt, + expiresAt: ExpiresAt, + revokedAt: RevokedAt, + securityVersionAtCreation: SecurityVersionAtCreation, + device: Device, + claims: Claims, + metadata: Metadata, + version: Version + 1 + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs new file mode 100644 index 00000000..e211cf04 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionChain.cs @@ -0,0 +1,271 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class UAuthSessionChain : IVersionedEntity +{ + public SessionChainId ChainId { get; } + public SessionRootId RootId { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset LastSeenAt { get; } + public DateTimeOffset? AbsoluteExpiresAt { get; } + public DeviceContext Device { get; } + public ClaimsSnapshot ClaimsSnapshot { get; } + public AuthSessionId? ActiveSessionId { get; } + public int RotationCount { get; } + public int TouchCount { get; } + public long SecurityVersionAtCreation { get; } + + public DateTimeOffset? RevokedAt { get; } + public long Version { get; set; } + + + public bool IsRevoked => RevokedAt is not null; + public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; + + private UAuthSessionChain( + SessionChainId chainId, + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? absoluteExpiresAt, + DeviceContext device, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + int rotationCount, + int touchCount, + long securityVersionAtCreation, + DateTimeOffset? revokedAt, + long version) + { + ChainId = chainId; + RootId = rootId; + Tenant = tenant; + UserKey = userKey; + CreatedAt = createdAt; + LastSeenAt = lastSeenAt; + AbsoluteExpiresAt = absoluteExpiresAt; + Device = device; + ClaimsSnapshot = claimsSnapshot; + ActiveSessionId = activeSessionId; + RotationCount = rotationCount; + TouchCount = touchCount; + SecurityVersionAtCreation = securityVersionAtCreation; + RevokedAt = revokedAt; + Version = version; + } + + public static UAuthSessionChain Create( + SessionChainId chainId, + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? expiresAt, + DeviceContext device, + ClaimsSnapshot claimsSnapshot, + long securityVersion) + { + return new UAuthSessionChain( + chainId, + rootId, + tenant, + userKey, + createdAt, + createdAt, + expiresAt, + device, + claimsSnapshot, + null, + rotationCount: 0, + touchCount: 0, + securityVersionAtCreation: securityVersion, + revokedAt: null, + version: 0 + ); + } + + public UAuthSessionChain AttachSession(AuthSessionId sessionId, DateTimeOffset now) + { + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + ClaimsSnapshot, + activeSessionId: sessionId, + RotationCount, // Unchanged on first attach + TouchCount, + SecurityVersionAtCreation, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain DetachSession(DateTimeOffset now) + { + if (ActiveSessionId is null) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + ClaimsSnapshot, + activeSessionId: null, + RotationCount, // Unchanged on first attach + TouchCount, + SecurityVersionAtCreation, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain RotateSession(AuthSessionId sessionId, DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) + { + if (IsRevoked || IsExpired(now)) + return this; + + if (ActiveSessionId.HasValue && ActiveSessionId.Value.Equals(sessionId)) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + activeSessionId: sessionId, + RotationCount + 1, + TouchCount, + SecurityVersionAtCreation, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain Touch(DateTimeOffset now, ClaimsSnapshot? claimsSnapshot = null) + { + if (IsRevoked || IsExpired(now)) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + lastSeenAt: now, + AbsoluteExpiresAt, + Device, + claimsSnapshot ?? ClaimsSnapshot, + ActiveSessionId, + RotationCount, + TouchCount + 1, + SecurityVersionAtCreation, + RevokedAt, + Version + 1 + ); + } + + public UAuthSessionChain Revoke(DateTimeOffset now) + { + if (IsRevoked) + return this; + + return new UAuthSessionChain( + ChainId, + RootId, + Tenant, + UserKey, + CreatedAt, + now, + AbsoluteExpiresAt, + Device, + ClaimsSnapshot, + ActiveSessionId, + RotationCount, + TouchCount, + SecurityVersionAtCreation, + revokedAt: now, + Version + 1 + ); + } + + internal static UAuthSessionChain FromProjection( + SessionChainId chainId, + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset lastSeenAt, + DateTimeOffset? expiresAt, + DeviceContext device, + ClaimsSnapshot claimsSnapshot, + AuthSessionId? activeSessionId, + int rotationCount, + int touchCount, + long securityVersionAtCreation, + DateTimeOffset? revokedAt, + long version) + { + return new UAuthSessionChain( + chainId, + rootId, + tenant, + userKey, + createdAt, + lastSeenAt, + expiresAt, + device, + claimsSnapshot, + activeSessionId, + rotationCount, + touchCount, + securityVersionAtCreation, + revokedAt, + version + ); + } + + private bool IsExpired(DateTimeOffset at) => AbsoluteExpiresAt.HasValue && at >= AbsoluteExpiresAt.Value; + + public SessionState GetState(DateTimeOffset at, TimeSpan? idleTimeout) + { + if (IsRevoked) + return SessionState.Revoked; + + if (IsExpired(at)) + return SessionState.Expired; + + if (idleTimeout.HasValue && at - LastSeenAt >= idleTimeout.Value) + return SessionState.Expired; + + return SessionState.Active; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs new file mode 100644 index 00000000..d50f9208 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Session/UAuthSessionRoot.cs @@ -0,0 +1,110 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed class UAuthSessionRoot : IVersionedEntity +{ + public SessionRootId RootId { get; } + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + + public DateTimeOffset CreatedAt { get; } + public DateTimeOffset? UpdatedAt { get; } + public DateTimeOffset? RevokedAt { get; } + + public long SecurityVersion { get; } + public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; + + private UAuthSessionRoot( + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? revokedAt, + long securityVersion, + long version) + { + RootId = rootId; + Tenant = tenant; + UserKey = userKey; + CreatedAt = createdAt; + UpdatedAt = updatedAt; + RevokedAt = revokedAt; + SecurityVersion = securityVersion; + Version = version; + } + + public static UAuthSessionRoot Create( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at) + { + return new UAuthSessionRoot( + SessionRootId.New(), + tenant, + userKey, + at, + null, + null, + 0, + 0 + ); + } + + public UAuthSessionRoot IncreaseSecurityVersion(DateTimeOffset at) + { + return new UAuthSessionRoot( + RootId, + Tenant, + UserKey, + CreatedAt, + at, + RevokedAt, + SecurityVersion + 1, + Version + 1 + ); + } + + public UAuthSessionRoot Revoke(DateTimeOffset at) + { + if (IsRevoked) + return this; + + return new UAuthSessionRoot( + RootId, + Tenant, + UserKey, + CreatedAt, + at, + at, + SecurityVersion + 1, + Version + 1 + ); + } + + internal static UAuthSessionRoot FromProjection( + SessionRootId rootId, + TenantKey tenant, + UserKey userKey, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? revokedAt, + long securityVersion, + long version) + { + return new UAuthSessionRoot( + rootId, + tenant, + userKey, + createdAt, + updatedAt, + revokedAt, + securityVersion, + version + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs new file mode 100644 index 00000000..4cf354c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/RefreshToken.cs @@ -0,0 +1,88 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed record RefreshToken : IVersionedEntity +{ + public TokenId TokenId { get; init; } + + public string TokenHash { get; init; } = default!; + + public TenantKey Tenant { get; init; } + + public required UserKey UserKey { get; init; } + + public AuthSessionId SessionId { get; init; } + + public SessionChainId? ChainId { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset ExpiresAt { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public string? ReplacedByTokenHash { get; init; } + + public long Version { get; set; } + + public bool IsRevoked => RevokedAt.HasValue; + + public bool IsExpired(DateTimeOffset now) + => ExpiresAt <= now; + + public bool IsActive(DateTimeOffset now) + => !IsRevoked && !IsExpired(now) && ReplacedByTokenHash is null; + + public static RefreshToken Create( + TokenId tokenId, + string tokenHash, + TenantKey tenant, + UserKey userKey, + AuthSessionId sessionId, + SessionChainId? chainId, + DateTimeOffset createdAt, + DateTimeOffset expiresAt) + { + return new RefreshToken + { + TokenId = tokenId, + TokenHash = tokenHash, + Tenant = tenant, + UserKey = userKey, + SessionId = sessionId, + ChainId = chainId, + CreatedAt = createdAt, + ExpiresAt = expiresAt, + Version = 0 + }; + } + + public RefreshToken Revoke(DateTimeOffset at, string? replacedBy = null) + { + if (IsRevoked) + return this; + + return this with + { + RevokedAt = at, + ReplacedByTokenHash = replacedBy, + Version = Version + 1 + }; + } + + public RefreshToken Replace(string newTokenHash, DateTimeOffset at) + { + if (IsRevoked) + throw new UAuthConflictException("Token already revoked."); + + return this with + { + RevokedAt = at, + ReplacedByTokenHash = newTokenHash, + Version = Version + 1 + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs new file mode 100644 index 00000000..19c704b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/TokenId.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(TokenIdJsonConverter))] +public readonly record struct TokenId(Guid Value) : IParsable +{ + public static TokenId New() => new(Guid.NewGuid()); + + public static TokenId From(Guid value) + => value == Guid.Empty + ? throw new ArgumentException("TokenId cannot be empty.", nameof(value)) + : new TokenId(value); + + public static bool TryCreate(string raw, out TokenId id) + { + if (Guid.TryParse(raw, out var guid) && guid != Guid.Empty) + { + id = new TokenId(guid); + return true; + } + + id = default; + return false; + } + + public static TokenId Parse(string s, IFormatProvider? provider) + { + if (TryParse(s, provider, out var id)) + return id; + + throw new FormatException("Invalid TokenId."); + } + + public static bool TryParse(string? s, IFormatProvider? provider, out TokenId result) + { + if (!string.IsNullOrWhiteSpace(s) && + Guid.TryParse(s, out var guid) && + guid != Guid.Empty) + { + result = new TokenId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString("N"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs new file mode 100644 index 00000000..1fe6ef8e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/Token/UAuthJwtTokenDescriptor.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +/// +/// Framework-agnostic JWT description used by IJwtTokenGenerator. +/// +public sealed class UAuthJwtTokenDescriptor +{ + public required string Subject { get; init; } + + public required string Issuer { get; init; } + + public required string Audience { get; init; } + + public required DateTimeOffset IssuedAt { get; init; } + public required DateTimeOffset ExpiresAt { get; init; } + public TenantKey Tenant { get; init; } + + public IReadOnlyDictionary? Claims { get; init; } + + public string? KeyId { get; init; } // kid +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs new file mode 100644 index 00000000..9099cbf6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/IAuthSubject.cs @@ -0,0 +1,20 @@ +๏ปฟ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/ICurrentUser.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/ICurrentUser.cs new file mode 100644 index 00000000..399cad5a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/ICurrentUser.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core; + +public interface ICurrentUser +{ + bool IsAuthenticated { get; } + UserKey UserKey { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs new file mode 100644 index 00000000..e45d5220 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserKey.cs @@ -0,0 +1,67 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Domain; + +[JsonConverter(typeof(UserKeyJsonConverter))] +public readonly record struct UserKey : IParsable +{ + public string Value { get; } + + private UserKey(string value) + { + Value = value; + } + + /// + /// Creates a UserKey from a GUID (default and recommended). + /// + public static UserKey FromGuid(Guid value) => new(value.ToString("N")); + + /// + /// Creates a UserKey from a canonical string. + /// Caller is responsible for stability and uniqueness. + /// + public static UserKey FromString(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("UserKey cannot be empty.", nameof(value)); + + return new UserKey(value); + } + + /// + /// Generates a new GUID-based UserKey. + /// + public static UserKey New() => FromGuid(Guid.NewGuid()); + + public static bool TryParse(string? s, IFormatProvider? provider, out UserKey result) + { + if (string.IsNullOrWhiteSpace(s)) + { + result = default; + return false; + } + + if (Guid.TryParse(s, out var guid)) + { + result = FromGuid(guid); + return true; + } + + result = FromString(s); + return true; + } + + public static UserKey Parse(string s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var result)) + throw new FormatException($"Invalid UserKey value: '{s}'"); + + return result; + } + + 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 new file mode 100644 index 00000000..8629eb13 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Domain/User/UserRuntimeRecord.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Domain; + +public sealed record UserRuntimeRecord +{ + public UserKey UserKey { get; init; } + public bool IsActive { get; init; } + public bool CanAuthenticate { get; init; } + public bool IsDeleted { get; init; } + public bool Exists { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs new file mode 100644 index 00000000..5eed57ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Authorization/UAuthChallengeRequiredException.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthChallengeRequiredException : UAuthRuntimeException +{ + public override int StatusCode => 401; + + public override string Title => "Reauthentication Required"; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/challenge"; + + public UAuthChallengeRequiredException(string? reason = null) + : base("challenge_required", reason ?? "Additional authentication is required.") + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs new file mode 100644 index 00000000..a1c4f367 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDeveloperException.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthDeveloperException : UAuthException +{ + protected UAuthDeveloperException(string code, string message) : base(code, message) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs new file mode 100644 index 00000000..20e974c5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthDomainException.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthDomainException : UAuthException +{ + protected UAuthDomainException(string code, string message) : base(code, message) + { + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs new file mode 100644 index 00000000..92d148a4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthException.cs @@ -0,0 +1,16 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthException : Exception +{ + public string Code { get; } + + protected UAuthException(string code, string message) : base(message) + { + Code = code; + } + + protected UAuthException(string code, string message, Exception? inner) : base(message, inner) + { + Code = code; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthRuntimeException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthRuntimeException.cs new file mode 100644 index 00000000..04a21e7e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Base/UAuthRuntimeException.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthRuntimeException : UAuthException +{ + public virtual int StatusCode => 400; + + public virtual string Title => "A request error occurred."; + + public virtual string TypePrefix => "https://docs.ultimateauth.com/errors"; + + protected UAuthRuntimeException(string code, string message) : base(code, message) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs new file mode 100644 index 00000000..b95fbac3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthenticationException.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthAuthenticationException : UAuthRuntimeException +{ + public override int StatusCode => 401; + public override string Title => "Unauthorized"; + + public UAuthAuthenticationException(string code = "authentication_required") + : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs new file mode 100644 index 00000000..c5ac0527 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthAuthorizationException.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthAuthorizationException : UAuthRuntimeException +{ + public override int StatusCode => 403; + + public override string Title => "Forbidden"; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/authorization"; + + public UAuthAuthorizationException(string code = "You do not have permission to perform this action.") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs new file mode 100644 index 00000000..c5d03c9d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthConcurrencyException.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthConcurrencyException : UAuthRuntimeException +{ + public override int StatusCode => 409; + + public override string Title => "The resource was modified by another process."; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/concurrency"; + + public UAuthConcurrencyException(string code = "concurrency_conflict") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthIdentifierException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthIdentifierException.cs new file mode 100644 index 00000000..7b0e02bf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthIdentifierException.cs @@ -0,0 +1,32 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthIdentifierException : UAuthRuntimeException +{ + public override string Title => "User identifier operation failed."; + public override string TypePrefix => "https://docs.ultimateauth.com/errors/identifiers"; + + protected UAuthIdentifierException(string code, string message) : base(code, message) + { + } +} + +public sealed class UAuthIdentifierConflictException : UAuthIdentifierException +{ + public override int StatusCode => 409; + public UAuthIdentifierConflictException(string code, string? message = null) + : base(code, message ?? code) { } +} + +public sealed class UAuthIdentifierValidationException : UAuthIdentifierException +{ + public override int StatusCode => 400; + public UAuthIdentifierValidationException(string code, string? message = null) + : base(code, message ?? code) { } +} + +public sealed class UAuthIdentifierNotFoundException : UAuthIdentifierException +{ + public override int StatusCode => 404; + public UAuthIdentifierNotFoundException(string code = "identifier_not_found", string? message = null) + : base(code, message ?? code) { } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs new file mode 100644 index 00000000..cc2468a0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/Runtime/UAuthNotFoundException.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public class UAuthNotFoundException : UAuthRuntimeException +{ + public override int StatusCode => 400; + + public override string Title => "The resource is not found."; + + public override string TypePrefix => "https://docs.ultimateauth.com/errors/notfound"; + + public UAuthNotFoundException(string code = "resource_not_found") : base(code, code) + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthConflictException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthConflictException.cs new file mode 100644 index 00000000..fd9de8bc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthConflictException.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthConflictException : UAuthRuntimeException +{ + public UAuthConflictException(string code) : base(code, "A resource conflict occurred.") + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthForbiddenException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthForbiddenException.cs new file mode 100644 index 00000000..4c2cfb76 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthForbiddenException.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthForbiddenException : UAuthRuntimeException +{ + public UAuthForbiddenException(string code = "forbidden") : base(code, "Forbidden.") + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthUnauthorizedException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthUnauthorizedException.cs new file mode 100644 index 00000000..77e90e64 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthUnauthorizedException.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthUnauthorizedException : UAuthRuntimeException +{ + public UAuthUnauthorizedException(string code = "unauthorized") : base(code, "Unauthorized.") + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Errors/UAuthValidationException.cs b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthValidationException.cs new file mode 100644 index 00000000..99b6b616 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Errors/UAuthValidationException.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Errors; + +public sealed class UAuthValidationException : UAuthRuntimeException +{ + public UAuthValidationException(string code) : base(code, "Validation failed.") + { + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs new file mode 100644 index 00000000..93713baa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/IAuthEventContext.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Marker interface for all UltimateAuth event context types. +/// +public interface IAuthEventContext { } diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs new file mode 100644 index 00000000..a442a7d9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEventDispatcher.cs @@ -0,0 +1,36 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Events; + +internal sealed class UAuthEventDispatcher +{ + private readonly UAuthEvents _events; + + public UAuthEventDispatcher(UAuthEvents events) + { + _events = events; + } + + public async Task DispatchAsync(IAuthEventContext context) + { + if (_events.OnAnyEvent is not null) + await SafeInvoke(() => _events.OnAnyEvent(context)); + + switch (context) + { + case UserLoggedInContext c: + if (_events.OnUserLoggedIn != null) + await SafeInvoke(() => _events.OnUserLoggedIn(c)); + break; + + case UserLoggedOutContext c: + if (_events.OnUserLoggedOut != null) + await SafeInvoke(() => _events.OnUserLoggedOut(c)); + break; + } + } + + private static async Task SafeInvoke(Func func) + { + try { await func(); } + catch { /* swallow โ†’ event hook must not break auth flow */ } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs new file mode 100644 index 00000000..9162c9fb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UAuthEvents.cs @@ -0,0 +1,50 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Provides an optional, application-wide event hook system for UltimateAuth. +/// +/// This class allows consumers to attach callbacks to authentication-related events +/// without implementing a full event bus or subscribing via DI. All handlers here are +/// optional and are executed after the corresponding operation completes successfully. +/// +/// IMPORTANT: +/// These delegates are designed for lightweight reactions such as: +/// - logging +/// - metrics +/// - notifications +/// - auditing +/// Custom business workflows **should not** be implemented here; instead, use dedicated +/// application services or a domain event bus for complex logic. +/// +/// All handlers receive an instance. The generic +/// type parameter is normalized as object to allow uniform handling regardless +/// of the actual TUserId type used by the application. +/// +public class UAuthEvents +{ + /// + /// Fired on every auth-related event. + /// This global hook allows logging, tracing or metrics pipelines to observe all events. + /// + public Func? OnAnyEvent { get; set; } + + /// + /// Fired when a user successfully completes the login process. + /// Note: separate from SessionCreated; this is a higher-level event. + /// + public Func? OnUserLoggedIn { get; set; } + + /// + /// Fired when a user logs out or all sessions for the user are revoked. + /// + public Func? OnUserLoggedOut { get; set; } + + internal UAuthEvents Clone() => new() + { + OnAnyEvent = OnAnyEvent, + OnUserLoggedIn = OnUserLoggedIn, + OnUserLoggedOut = OnUserLoggedOut + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs new file mode 100644 index 00000000..e882818a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedInContext.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Represents contextual data emitted when a user successfully completes the login process. +/// +/// This event is triggered after the authentication workflow validates credentials +/// (or external identity provider assertions) and before or after the session creation step, +/// depending on pipeline configuration. +/// +/// Typical use cases include: +/// - auditing successful logins +/// - triggering login notifications +/// - updating user activity dashboards +/// - integrating with SIEM or monitoring systems +/// +/// NOTE: +/// This event is distinct from session create. +/// A user may log in without creating a new session (e.g., external SSO), +/// or multiple sessions may be created after a single login depending on client application flows. +/// +public sealed class UserLoggedInContext : IAuthEventContext +{ + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public DateTimeOffset LoggedInAt { get; } + + public DeviceContext? Device { get; } + public AuthSessionId? SessionId { get; } + + public UserLoggedInContext( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at, + DeviceContext? device = null, + AuthSessionId? sessionId = null) + { + Tenant = tenant; + UserKey = userKey; + LoggedInAt = at; + Device = device; + SessionId = sessionId; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs new file mode 100644 index 00000000..1d7f46ac --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Events/UserLoggedOutContext.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Events; + +/// +/// Represents contextual data emitted when a user logs out of the system. +/// +/// This event is triggered when a logout operation is executed โ€” either by explicit user action, automatic revocation, +/// administrative force-logout, or tenant-level security policies. +/// +/// Unlike session revoke which targets a specific session, this event reflects a higher-level โ€œuser has logged outโ€ state and may +/// represent logout from a single session or all sessions depending on the workflow. +/// +/// Typical use cases include: +/// - audit logging of logout activities +/// - updating user presence or activity services +/// - triggering notifications (e.g., โ€œYou have logged out from device Xโ€) +/// - integrating with analytics or SIEM systems +/// +public sealed class UserLoggedOutContext : IAuthEventContext +{ + public TenantKey Tenant { get; } + public UserKey UserKey { get; } + public DateTimeOffset LoggedOutAt { get; } + + public AuthSessionId? SessionId { get; } + public LogoutReason Reason { get; } + + public UserLoggedOutContext( + TenantKey tenant, + UserKey userKey, + DateTimeOffset at, + LogoutReason reason, + AuthSessionId? sessionId = null) + { + Tenant = tenant; + UserKey = userKey; + LoggedOutAt = at; + Reason = reason; + SessionId = sessionId; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/.gitkeep b/src/CodeBeam.UltimateAuth.Core/Extensions/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Extensions/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs new file mode 100644 index 00000000..49ec65db --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ClaimsSnapshotExtensions.cs @@ -0,0 +1,75 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Core.Extensions; + +public static class ClaimsSnapshotExtensions +{ + /// + /// Converts a ClaimsSnapshot into an ASP.NET Core ClaimsPrincipal. + /// + public static ClaimsPrincipal ToClaimsPrincipal(this ClaimsSnapshot snapshot, string authenticationType = UAuthConstants.SchemeDefaults.GlobalScheme) + { + if (snapshot == null) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var claims = snapshot.Claims.SelectMany(kv => kv.Value.Select(value => new Claim(kv.Key, value))); + + var identity = new ClaimsIdentity(claims, authenticationType); + return new ClaimsPrincipal(identity); + } + + public static ClaimsPrincipal ToClaimsPrincipal(this AuthStateSnapshot snapshot, string authenticationType) + { + var claims = snapshot.Claims.ToClaims().ToList(); + + claims.Add(new Claim(ClaimTypes.NameIdentifier, snapshot.Identity.UserKey.Value)); + + if (!string.IsNullOrWhiteSpace(snapshot.Identity.PrimaryUserName)) + claims.Add(new Claim(ClaimTypes.Name, snapshot.Identity.PrimaryUserName)); + + var ci = new ClaimsIdentity(claims, authenticationType, ClaimTypes.Name, ClaimTypes.Role); + return new ClaimsPrincipal(ci); + } + + /// + /// Converts an ASP.NET Core ClaimsPrincipal into a ClaimsSnapshot. + /// + public static ClaimsSnapshot ToClaimsSnapshot(this ClaimsPrincipal principal) + { + if (principal is null) + return ClaimsSnapshot.Empty; + + if (principal.Identity?.IsAuthenticated != true) + return ClaimsSnapshot.Empty; + + var dict = new Dictionary>(StringComparer.Ordinal); + + foreach (var claim in principal.Claims) + { + if (!dict.TryGetValue(claim.Type, out var set)) + { + set = new HashSet(StringComparer.Ordinal); + dict[claim.Type] = set; + } + + set.Add(claim.Value); + } + + return new ClaimsSnapshot(dict.ToDictionary(kv => kv.Key, kv => (IReadOnlyCollection)kv.Value.ToArray(), StringComparer.Ordinal)); + } + + public static IEnumerable ToClaims(this ClaimsSnapshot snapshot) + { + foreach (var (type, values) in snapshot.Claims) + { + foreach (var value in values) + { + yield return new Claim(type, value); + } + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5cfc2f39 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,81 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Extensions; + +/// +/// Provides extension methods for registering UltimateAuth core services into the application's dependency injection container. +/// +/// These methods configure options, validators, converters, and factories required for the authentication subsystem. +/// +/// IMPORTANT: +/// This extension registers only CORE services โ€” session stores, token factories, PKCE handlers, and any server-specific +/// logic must be added from the Server package (e.g., AddUltimateAuthServer()). +/// +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuth(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + var optionsBuilder = services.AddOptions(); + + if (configure is not null) + { + optionsBuilder.Configure((options, marker) => + { + marker.MarkConfigured(); + configure(options); + }); + } + + optionsBuilder.BindConfiguration("UltimateAuth:Core"); + + services.TryAddSingleton>(sp => + { + var marker = sp.GetRequiredService(); + var config = sp.GetService(); + + return new CoreConfigurationIntentDetector(marker, config); + }); + + return services.AddUltimateAuthInternal(); + } + + /// + /// Internal shared registration pipeline invoked by all AddUltimateAuth overloads. + /// Registers validators, user ID converters, and placeholder factories. + /// Core-level invariant validation. + /// Server layer may add additional validators. + /// NOTE: + /// This method does not register session stores or server-side services. + /// A server project must explicitly call: + /// "services.AddUltimateAuthSessionStore'TStore'();" + /// to provide a concrete ISessionStore implementation. + /// + private static IServiceCollection AddUltimateAuthInternal(this IServiceCollection services) + { + services.TryAddSingleton(); + services.AddSingleton, UAuthOptionsPostConfigureGuard>(); + + + services.AddSingleton, UAuthOptionsValidator>(); + services.AddSingleton, UAuthSessionOptionsValidator>(); + services.AddSingleton, UAuthTokenOptionsValidator>(); + services.AddSingleton, UAuthLoginOptionsValidator>(); + services.AddSingleton, UAuthPkceOptionsValidator>(); + services.AddSingleton, UAuthMultiTenantOptionsValidator>(); + + services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs b/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs new file mode 100644 index 00000000..8c87ae75 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Extensions/UserIdConverterRegistrationExtensions.cs @@ -0,0 +1,63 @@ +๏ปฟusing Microsoft.Extensions.DependencyInjection; +using CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Extensions; + +// TODO: Decide converter obligatory or optional on boundary UserKey TUserId conversion +/// +/// Provides extension methods for registering custom +/// implementations into the dependency injection container. +/// +/// UltimateAuth internally relies on user ID normalization for: +/// - session store lookups +/// - token generation and validation +/// - logging and diagnostics +/// - multi-tenant user routing +/// +/// By default, a simple "UAuthUserIdConverter{TUserId}" is used, but +/// applications may override this with stronger or domain-specific converters +/// (e.g., ULIDs, Snowflakes, encrypted identifiers, composite keys). +/// +public static class UserIdConverterRegistrationExtensions +{ + /// + /// Registers a custom implementation. + /// + /// Use this overload when you want to supply your own converter type. + /// Ideal for stateless converters that simply translate user IDs to/from + /// string or byte representations (database keys, token subjects, etc.). + /// + /// The converter is registered as a singleton because: + /// - conversion is pure and stateless, + /// - high-performance lookup is required, + /// - converters are reused across multiple services (tokens, sessions, stores). + /// + /// The application's user ID type. + /// The custom converter implementation. + public static IServiceCollection AddUltimateAuthUserIdConverter( + this IServiceCollection services) + where TConverter : class, IUserIdConverter + { + services.AddSingleton, TConverter>(); + return services; + } + +#pragma warning disable CS1573 + /// + /// Registers a specific instance of . + /// + /// Use this overload when: + /// - the converter requires configuration or external initialization, + /// - the converter contains state (e.g., encryption keys, salt pools), + /// - multiple converters need DI-managed lifetime control. + /// + /// The application's user ID type. + /// The converter instance to register. + public static IServiceCollection AddUltimateAuthUserIdConverter( + this IServiceCollection services, + IUserIdConverter instance) + { + services.AddSingleton(instance); + return services; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs new file mode 100644 index 00000000..9f5179ce --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/AuthAuthority.cs @@ -0,0 +1,49 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class AuthAuthority : IAuthAuthority +{ + private readonly IEnumerable _invariants; + private readonly IEnumerable _policies; + + public AuthAuthority(IEnumerable invariants, IEnumerable policies) + { + _invariants = invariants ?? Array.Empty(); + _policies = policies ?? Array.Empty(); + } + + public AccessDecisionResult Decide(AuthContext context, IEnumerable? policies = null) + { + foreach (var invariant in _invariants) + { + var result = invariant.Decide(context); + if (!result.IsAllowed) + return result; + } + + bool challenged = false; + + var effectivePolicies = _policies.Concat(policies ?? Enumerable.Empty()); + + foreach (var policy in effectivePolicies) + { + if (!policy.AppliesTo(context)) + continue; + + var result = policy.Decide(context); + + if (!result.IsAllowed) + return result; + + if (result.RequiresChallenge) + challenged = true; + } + + return challenged + ? AccessDecisionResult.Challenge("Additional verification required.") + : AccessDecisionResult.Allow(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs new file mode 100644 index 00000000..d854cf74 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/DeviceRequiredInvariant.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DeviceRequiredInvariant : IAuthorityInvariant +{ + public AccessDecisionResult Decide(AuthContext context) + { + if (!RequiresDevice(context)) + return AccessDecisionResult.Allow(); + + if (!context.Device.HasDeviceId) + return AccessDecisionResult.Deny("DeviceId is required for this operation."); + + return AccessDecisionResult.Allow(); + } + + private static bool RequiresDevice(AuthContext context) + { + if (context.Operation is AuthOperation.Login or AuthOperation.Refresh) + { + return context.ClientProfile switch + { + UAuthClientProfile.BlazorWasm => true, + UAuthClientProfile.BlazorServer => true, + UAuthClientProfile.Maui => true, + _ => false // service, system, internal + }; + } + + return false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs new file mode 100644 index 00000000..6ef97ad7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/ExpiredSessionInvariant.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class ExpiredSessionInvariant : IAuthorityInvariant +{ + public AccessDecisionResult Decide(AuthContext context) + { + if (context.Operation == AuthOperation.Login) + return AccessDecisionResult.Allow(); + + var session = context.Session; + + if (session is null) + return AccessDecisionResult.Allow(); + + if (session.State == SessionState.Expired) + { + return AccessDecisionResult.Deny("Session has expired."); + } + + return AccessDecisionResult.Allow(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs new file mode 100644 index 00000000..0b971799 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/InvalidOrRevokedSessionInvariant.cs @@ -0,0 +1,30 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class InvalidOrRevokedSessionInvariant : IAuthorityInvariant +{ + public AccessDecisionResult Decide(AuthContext context) + { + if (context.Operation == AuthOperation.Login) + return AccessDecisionResult.Allow(); + + var session = context.Session; + + if (session is null) + return AccessDecisionResult.Deny("Session is required for this operation."); + + if (session.State == SessionState.Invalid || + session.State == SessionState.NotFound || + session.State == SessionState.Revoked || + session.State == SessionState.SecurityMismatch || + session.State == SessionState.DeviceMismatch) + { + return AccessDecisionResult.Deny($"Session state is invalid: {session.State}"); + } + + return AccessDecisionResult.Allow(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs new file mode 100644 index 00000000..7f8b6be3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Authority/TenantResolvedInvariant.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class TenantResolvedInvariant : IAuthorityInvariant +{ + public AccessDecisionResult Decide(AuthContext context) + { + if (context.Tenant.IsUnresolved) + { + return AccessDecisionResult.Deny("Tenant is not resolved."); + } + + return AccessDecisionResult.Allow(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs new file mode 100644 index 00000000..14b2de0d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Base64Url.cs @@ -0,0 +1,43 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Infrastructure; + +/// +/// Provides Base64 URL-safe encoding and decoding utilities. +/// +/// RFC 4648-compliant transformation replacing '+' โ†’ '-', '/' โ†’ '_' +/// and removing padding characters '='. Commonly used in PKCE, +/// JWT segments, and opaque token representations. +/// +public static class Base64Url +{ + /// + /// Encodes a byte array into a URL-safe Base64 string by applying + /// RFC 4648 URL-safe transformations and removing padding. + /// + /// The binary data to encode. + /// A URL-safe Base64 encoded string. + public static string Encode(byte[] input) + { + var base64 = Convert.ToBase64String(input); + return base64.Replace("+", "-").Replace("/", "_").Replace("=", ""); + } + + /// + /// Decodes a URL-safe Base64 string into its original binary form. + /// Automatically restores required padding before decoding. + /// + /// The URL-safe Base64 encoded string. + /// The decoded binary data. + public static byte[] Decode(string input) + { + var padded = input.Replace("-", "+").Replace("_", "/"); + + switch (padded.Length % 4) + { + case 2: padded += "=="; break; + case 3: padded += "="; break; + } + + return Convert.FromBase64String(padded); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs new file mode 100644 index 00000000..e59512b6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/AuthSessionIdJsonConverter.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class AuthSessionIdJsonConverter : JsonConverter +{ + public override AuthSessionId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("AuthSessionId must be a string."); + + var value = reader.GetString(); + + if (!AuthSessionId.TryCreate(value, out var id)) + throw new JsonException($"Invalid AuthSessionId value: '{value}'"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, AuthSessionId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs new file mode 100644 index 00000000..e0e8b013 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceContextJsonConverter.cs @@ -0,0 +1,65 @@ +๏ปฟusing System.Text.Json; +using System.Text.Json.Serialization; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DeviceContextJsonConverter : JsonConverter +{ + public override DeviceContext Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + throw new JsonException("DeviceContext must be an object."); + + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + + // DeviceId + DeviceId? deviceId = null; + if (root.TryGetProperty("deviceId", out var deviceIdProp)) + { + var raw = deviceIdProp.GetString(); + + if (!string.IsNullOrWhiteSpace(raw)) + { + if (!DeviceId.TryCreate(raw, out var parsed)) + throw new JsonException("Invalid DeviceId"); + + deviceId = parsed; + } + } + + string? deviceType = root.TryGetProperty("deviceType", out var dt) ? dt.GetString() : null; + string? platform = root.TryGetProperty("platform", out var pf) ? pf.GetString() : null; + string? os = root.TryGetProperty("operatingSystem", out var osProp) ? osProp.GetString() : null; + string? browser = root.TryGetProperty("browser", out var br) ? br.GetString() : null; + string? ip = root.TryGetProperty("ipAddress", out var ipProp) ? ipProp.GetString() : null; + + if (deviceId is not DeviceId resolvedDeviceId) + return DeviceContext.Anonymous(); + + return DeviceContext.Create( + resolvedDeviceId, + deviceType, + platform, + os, + browser, + ip); + } + + public override void Write(Utf8JsonWriter writer, DeviceContext value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value.DeviceId is not null) + writer.WriteString("deviceId", (string)value.DeviceId); + + writer.WriteString("deviceType", value.DeviceType); + writer.WriteString("platform", value.Platform); + writer.WriteString("operatingSystem", value.OperatingSystem); + writer.WriteString("browser", value.Browser); + writer.WriteString("ipAddress", value.IpAddress); + + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs new file mode 100644 index 00000000..95299f6c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/DeviceIdJsonConverter.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class DeviceIdJsonConverter : JsonConverter +{ + public override DeviceId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var value = reader.GetString(); + + if (!DeviceId.TryCreate(value, out var id)) + throw new JsonException("Invalid DeviceId"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, DeviceId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs new file mode 100644 index 00000000..ae49bcda --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionChainIdJsonConverter.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class SessionChainIdJsonConverter : JsonConverter +{ + public override SessionChainId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("SessionChainId must be a string."); + + var raw = reader.GetString(); + + if (!SessionChainId.TryCreate(raw!, out var id)) + throw new JsonException($"Invalid SessionChainId value: '{raw}'"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, SessionChainId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value.ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs new file mode 100644 index 00000000..24c3c08f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/SessionRootIdJsonConverter.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +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."); + + var raw = reader.GetString(); + + if (!SessionRootId.TryCreate(raw!, out var id)) + throw new JsonException($"Invalid SessionChainId value: '{raw}'"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, SessionRootId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value.ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs new file mode 100644 index 00000000..b0d42356 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TenantKeyJsonConverter.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class TenantKeyJsonConverter : JsonConverter +{ + public override TenantKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("TenantKey must be a string."); + + var value = reader.GetString(); + + if (string.IsNullOrWhiteSpace(value)) + throw new JsonException("TenantKey cannot be null or empty."); + + // IMPORTANT: + // JSON transport = internal framework boundary + // Do NOT use FromExternal here. + return TenantKey.FromInternal(value); + } + + public override void Write(Utf8JsonWriter writer, TenantKey value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs new file mode 100644 index 00000000..25271f91 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/TokenIdJsonConverter.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class TokenIdJsonConverter : JsonConverter +{ + public override TokenId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("TokenId must be a string."); + + var raw = reader.GetString(); + + if (!TokenId.TryCreate(raw!, out var id)) + throw new JsonException($"Invalid TokenId value: '{raw}'"); + + return id; + } + + public override void Write(Utf8JsonWriter writer, TokenId value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value.ToString("N")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs new file mode 100644 index 00000000..9949db9b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UAuthUserIdConverter.cs @@ -0,0 +1,111 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +/// +/// Default implementation of that provides +/// normalization and serialization for user identifiers. +/// +/// Supports primitive types (, , , ) +/// with optimized formats. For custom types, JSON serialization is used as a safe fallback. +/// +/// Converters are used throughout UltimateAuth for: +/// - token generation +/// - session-store keys +/// - multi-tenancy boundaries +/// - logging and diagnostics +/// +public sealed class UAuthUserIdConverter : IUserIdConverter +{ + /// + /// Converts the specified user id into a canonical string representation. + /// Primitive types use invariant culture or compact formats; complex objects + /// are serialized via JSON. + /// + /// The user identifier to convert. + /// A normalized string representation of the user id. + public string ToCanonicalString(TUserId id) + { + return id switch + { + UserKey v => v.Value, + Guid v => v.ToString("N"), + string v => v, + int v => v.ToString(CultureInfo.InvariantCulture), + long v => v.ToString(CultureInfo.InvariantCulture), + + _ => throw new InvalidOperationException($"Unsupported UserId type: {typeof(TUserId).FullName}. " + + "Provide a custom IUserIdConverter.") + }; + } + + /// + /// Converts the user id into UTF-8 encoded bytes derived from its + /// normalized string representation. + /// + /// The user identifier to convert. + /// UTF-8 encoded bytes representing the user id. + public byte[] ToBytes(TUserId id) => Encoding.UTF8.GetBytes(ToCanonicalString(id)); + + /// + /// Converts a canonical string representation back into a user id. + /// Supports primitives and restores complex types via JSON deserialization. + /// + /// The string representation of the user id. + /// The reconstructed user id. + public TUserId FromString(string value) + { + return typeof(TUserId) switch + { + Type t when t == typeof(UserKey) => (TUserId)(object)UserKey.FromString(value), + Type t when t == typeof(Guid) => (TUserId)(object)Guid.Parse(value), + Type t when t == typeof(string) => (TUserId)(object)value, + Type t when t == typeof(int) => (TUserId)(object)int.Parse(value, CultureInfo.InvariantCulture), + Type t when t == typeof(long) => (TUserId)(object)long.Parse(value, CultureInfo.InvariantCulture), + + _ => JsonSerializer.Deserialize(value) + ?? throw new InvalidCastException("Cannot deserialize TUserId") + }; + } + + public bool TryFromString(string value, out TUserId id) + { + try + { + id = FromString(value); + return true; + } + catch + { + id = default!; + return false; + } + } + + /// + /// Converts a UTF-8 encoded binary representation back into a user id. + /// + /// Binary data representing the user id. + /// The reconstructed user id. + public TUserId FromBytes(byte[] binary) => FromString(Encoding.UTF8.GetString(binary)); + + public bool TryFromBytes(byte[] binary, out TUserId id) + { + try + { + id = FromBytes(binary); + return true; + } + catch + { + id = default!; + return false; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs new file mode 100644 index 00000000..0a4da433 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/Converters/UserKeyJsonConverter.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +// NOTE: +// UserKey is the canonical domain identity type. +// JSON serialization/deserialization is intentionally direct and does not use IUserIdConverterResolver. +public sealed class UserKeyJsonConverter : JsonConverter +{ + public override UserKey Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + throw new JsonException("UserKey must be a string."); + + return UserKey.FromString(reader.GetString()!); + } + + public override void Write(Utf8JsonWriter writer, UserKey value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.Value); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs new file mode 100644 index 00000000..b96d09a0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/GuidUserIdFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class GuidUserIdFactory : IUserIdFactory +{ + public Guid Create() => Guid.NewGuid(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs new file mode 100644 index 00000000..18fa200a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/NoOpAccessTokenIdStore.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +internal sealed class NoopAccessTokenIdStore : IAccessTokenIdStore +{ + public Task StoreAsync(TenantKey tenant, string jti, DateTimeOffset expiresAt, CancellationToken ct = default) + => Task.CompletedTask; + + public Task IsRevokedAsync(TenantKey tenant, string jti, CancellationToken ct = default) + => Task.FromResult(false); + + public Task RevokeAsync(TenantKey tenant, string jti, DateTimeOffset revokedAt, CancellationToken ct = default) + => Task.CompletedTask; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs new file mode 100644 index 00000000..6ccb72ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SeedRunner.cs @@ -0,0 +1,30 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class SeedRunner +{ + private readonly IEnumerable _contributors; + + public SeedRunner(IEnumerable contributors) + { + _contributors = contributors; + + foreach (var c in contributors) + { + Console.WriteLine($"- {c.GetType().FullName}"); + } + } + + public async Task RunAsync(TenantKey? tenant, CancellationToken ct = default) + { + if (tenant == null) + tenant = TenantKey.Single; + + foreach (var c in _contributors.OrderBy(x => x.Order)) + { + await c.SeedAsync((TenantKey)tenant, ct); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs new file mode 100644 index 00000000..0e0ab036 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/SessionValidationMapper.cs @@ -0,0 +1,92 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public static class SessionValidationMapper +{ + public static SessionValidationResult ToDomain(SessionValidationInfo dto) + { + var state = (SessionState)dto.State; + + if (!dto.IsValid || dto.Snapshot.Identity is null) + { + return SessionValidationResult.Invalid(state); + } + + var tenant = TenantKey.FromInternal(dto.Snapshot.Identity.Tenant); + + UserKey? userKey = string.IsNullOrWhiteSpace(dto.Snapshot.Identity.UserKey) + ? null + : UserKey.Parse(dto.Snapshot.Identity.UserKey, null); + + ClaimsSnapshot claims; + + if (dto.Snapshot.Claims is null) + { + claims = ClaimsSnapshot.Empty; + } + else + { + var builder = ClaimsSnapshot.Create(); + + foreach (var (type, values) in dto.Snapshot.Claims.Claims) + { + builder.AddMany(type, values); + } + + foreach (var role in dto.Snapshot.Claims.Roles) + { + builder.AddRole(role); + } + + foreach (var permission in dto.Snapshot.Claims.Permissions) + { + builder.AddPermission(permission); + } + + claims = builder.Build(); + } + + AuthSessionId.TryCreate("temp", out AuthSessionId tempSessionId); + + return SessionValidationResult.Active( + tenant, + userKey, + tempSessionId, // TODO: This is TEMP add real + SessionChainId.New(), // TEMP + SessionRootId.New(), // TEMP + claims, + dto.Snapshot.Identity.AuthenticatedAt ?? DateTimeOffset.UtcNow, + null + ); + } + + public static SessionSecurityContext? ToSecurityContext(SessionValidationResult result) + { + if (!result.IsValid) + { + if (result?.SessionId is null) + return null; + + return new SessionSecurityContext + { + SessionId = result.SessionId.Value, + State = result.State, + ChainId = result.ChainId, + UserKey = result.UserKey, + BoundDeviceId = result.BoundDeviceId + }; + } + + return new SessionSecurityContext + { + SessionId = result.SessionId!.Value, + State = SessionState.Active, + ChainId = result.ChainId, + UserKey = result.UserKey, + BoundDeviceId = result.BoundDeviceId + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs new file mode 100644 index 00000000..12c7f209 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/StringUserIdFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class StringUserIdFactory : IUserIdFactory +{ + public string Create() => Guid.NewGuid().ToString("N"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs new file mode 100644 index 00000000..349dd8ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthRefreshTokenValidator.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class UAuthRefreshTokenValidator : IRefreshTokenValidator +{ + private readonly IRefreshTokenStoreFactory _storeFactory; + private readonly ITokenHasher _hasher; + + public UAuthRefreshTokenValidator(IRefreshTokenStoreFactory storeFactory, ITokenHasher hasher) + { + _storeFactory = storeFactory; + _hasher = hasher; + } + + public async Task ValidateAsync(RefreshTokenValidationContext context, CancellationToken ct = default) + { + var store = _storeFactory.Create(context.Tenant); + var hash = _hasher.Hash(context.RefreshToken); + var stored = await store.FindByHashAsync(hash, ct); + + if (stored is null) + return RefreshTokenValidationResult.Invalid(); + + if (stored.IsRevoked) + return RefreshTokenValidationResult.ReuseDetected( + tenant: stored.Tenant, + sessionId: stored.SessionId, + chainId: stored.ChainId, + userKey: stored.UserKey); + + if (stored.IsExpired(context.Now)) + { + await store.RevokeAsync(hash, context.Now, null, ct); + return RefreshTokenValidationResult.Invalid(); + } + + if (context.ExpectedSessionId.HasValue && stored.SessionId != context.ExpectedSessionId) + { + return RefreshTokenValidationResult.Invalid(); + } + + // TODO: Add device binding + // if (context.Device != null && !stored.MatchesDevice(context.Device)) + // return Invalid(); + + return RefreshTokenValidationResult.Valid( + tenant: stored.Tenant, + stored.UserKey, + stored.SessionId, + hash, + stored.ChainId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs new file mode 100644 index 00000000..9c3e3a58 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserAgentParser.cs @@ -0,0 +1,85 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +internal sealed class UAuthUserAgentParser : IUserAgentParser +{ + public UserAgentInfo Parse(string? userAgent) + { + if (string.IsNullOrWhiteSpace(userAgent)) + return new UserAgentInfo(); + + var ua = userAgent.ToLowerInvariant(); + + return new UserAgentInfo + { + DeviceType = ResolveDeviceType(ua), + Platform = ResolvePlatform(ua), + OperatingSystem = ResolveOperatingSystem(ua), + Browser = ResolveBrowser(ua) + }; + } + + private static string ResolveDeviceType(string ua) + { + if (ua.Contains("ipad") || ua.Contains("tablet")) + return "tablet"; + + if (ua.Contains("mobi") || ua.Contains("iphone") || ua.Contains("android")) + return "mobile"; + + return "desktop"; + } + + private static string ResolvePlatform(string ua) + { + if (ua.Contains("ipad") || ua.Contains("tablet")) + return "tablet"; + + if (ua.Contains("mobi") || ua.Contains("iphone") || ua.Contains("android")) + return "mobile"; + + return "desktop"; + } + + private static string ResolveOperatingSystem(string ua) + { + if (ua.Contains("android")) + return "android"; + + if (ua.Contains("iphone") || ua.Contains("ipad")) + return "ios"; + + if (ua.Contains("windows")) + return "windows"; + + if (ua.Contains("mac")) + return "macos"; + + if (ua.Contains("linux")) + return "linux"; + + return "unknown"; + } + + private static string ResolveBrowser(string ua) + { + if (ua.Contains("edg/")) + return "edge"; + + if (ua.Contains("opr/") || ua.Contains("opera")) + return "opera"; + + if (ua.Contains("chrome") && !ua.Contains("chromium")) + return "chrome"; + + if (ua.Contains("safari") && !ua.Contains("chrome")) + return "safari"; + + if (ua.Contains("firefox")) + return "firefox"; + + return "unknown"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs new file mode 100644 index 00000000..8c4c716f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UAuthUserIdConverterResolver.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +/// +/// Resolves instances from the DI container. +/// +/// If no custom converter is registered for a given TUserId, this resolver falls back +/// to the default implementation. +/// +/// This allows applications to optionally plug in specialized converters for certain +/// user id types while retaining safe defaults for all others. +/// +public sealed class UAuthUserIdConverterResolver : IUserIdConverterResolver +{ + private readonly IServiceProvider _sp; + + /// + /// Initializes a new instance of the class. + /// + /// The service provider used to resolve converters from DI. + public UAuthUserIdConverterResolver(IServiceProvider sp) + { + _sp = sp; + } + + /// + /// Returns a converter for the specified TUserId type. + /// + /// Resolution order: + /// 1. Try to resolve from DI. + /// 2. If not found, return a new instance. + /// + /// The user id type for which to resolve a converter. + /// An instance. + public IUserIdConverter GetConverter(string? provider) + { + var converter = _sp.GetService>(); + if (converter != null) + return converter; + + return new UAuthUserIdConverter(); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs new file mode 100644 index 00000000..f024b89d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Infrastructure/UserIdFactory.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Core.Infrastructure; + +public sealed class UserIdFactory : IUserIdFactory +{ + public UserKey Create() => UserKey.New(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Models/.gitkeep b/src/CodeBeam.UltimateAuth.Core/Models/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Models/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs new file mode 100644 index 00000000..ffc9040e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/CompositeTenantResolver.cs @@ -0,0 +1,36 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Executes multiple tenant resolvers in order; the first resolver returning a non-null tenant id wins. +/// +public sealed class CompositeTenantResolver : ITenantIdResolver +{ + private readonly IReadOnlyList _resolvers; + + /// + /// Creates a composite resolver that will evaluate the provided resolvers sequentially. + /// + /// Ordered list of resolvers to execute. + public CompositeTenantResolver(IEnumerable resolvers) + { + _resolvers = resolvers.ToList(); + } + + /// + /// Executes each resolver in sequence and returns the first non-null tenant id. + /// Returns null if no resolver can determine a tenant id. + /// + /// Resolution context containing user id, session, request metadata, etc. + public async Task ResolveTenantIdAsync(TenantResolutionContext context) + { + foreach (var resolver in _resolvers) + { + var tid = await resolver.ResolveTenantIdAsync(context); + if (!string.IsNullOrWhiteSpace(tid)) + return tid; + } + + return null; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs new file mode 100644 index 00000000..83a675a7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/FixedTenantResolver.cs @@ -0,0 +1,16 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +public sealed class FixedTenantResolver : ITenantIdResolver +{ + private readonly string _tenantId; + + public FixedTenantResolver(string tenantId) + { + if (string.IsNullOrWhiteSpace(tenantId)) + throw new ArgumentException("Tenant id cannot be empty.", nameof(tenantId)); + + _tenantId = tenantId; + } + + public Task ResolveTenantIdAsync(TenantResolutionContext context) => Task.FromResult(_tenantId); +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs new file mode 100644 index 00000000..512ef583 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HeaderTenantResolver.cs @@ -0,0 +1,37 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Resolves the tenant id from a specific HTTP header. +/// Example: X-Tenant: foo โ†’ returns "foo". +/// Useful when multi-tenancy is controlled by API gateways or reverse proxies. +/// +public sealed class HeaderTenantResolver : ITenantIdResolver +{ + private readonly string _headerName; + + /// + /// Creates a resolver that reads the tenant id from the given header name. + /// + /// The name of the HTTP header to inspect. + public HeaderTenantResolver(string headerName) + { + _headerName = headerName; + } + + /// + /// Attempts to resolve the tenant id by reading the configured header from the request context. + /// Returns null if the header is missing or empty. + /// + public Task ResolveTenantIdAsync(TenantResolutionContext context) + { + if (context.Headers != null && + context.Headers.TryGetValue(_headerName, out var value) && + !string.IsNullOrWhiteSpace(value)) + { + return Task.FromResult(value.ToString().Trim()); + } + + return Task.FromResult(null); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs new file mode 100644 index 00000000..02ab394f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/HostTenantResolver.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Resolves the tenant id based on the request host name. +/// Example: foo.example.com โ†’ returns "foo". +/// Useful in subdomain-based multi-tenant architectures. +/// +public sealed class HostTenantResolver : ITenantIdResolver +{ + /// + /// Attempts to resolve the tenant id from the host portion of the incoming request. + /// Returns null if the host is missing, invalid, or does not contain a subdomain. + /// + public Task ResolveTenantIdAsync(TenantResolutionContext context) + { + var host = context.Host; + + if (string.IsNullOrWhiteSpace(host)) + return Task.FromResult(null); + + var parts = host.Split('.', StringSplitOptions.RemoveEmptyEntries); + + // Expecting at least: {tenant}.{domain}.{tld} + if (parts.Length < 3) + return Task.FromResult(null); + + return Task.FromResult(parts[0]); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs new file mode 100644 index 00000000..5289e08a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/ITenantIdResolver.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Defines a strategy for resolving the tenant id for the current request. +/// Implementations may extract the tenant from headers, hostnames, +/// authentication tokens, or any other application-defined source. +/// +public interface ITenantIdResolver +{ + /// + /// Attempts to resolve the tenant id given the contextual request data. + /// Returns null when no tenant can be determined. + /// + Task ResolveTenantIdAsync(TenantResolutionContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs new file mode 100644 index 00000000..820f43ca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/PathTenantResolver.cs @@ -0,0 +1,23 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +public sealed class PathTenantResolver : ITenantIdResolver +{ + /// + /// Extracts the tenant id from the request path, if present. + /// Returns null when the prefix is not matched or the path is insufficient. + /// + public Task ResolveTenantIdAsync(TenantResolutionContext context) + { + var path = context.Path; + if (string.IsNullOrWhiteSpace(path)) + return Task.FromResult(null); + + var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries); + + // Format: /{tenant}/... + if (segments.Length >= 1) + return Task.FromResult(segments[0]); + + return Task.FromResult(null); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs new file mode 100644 index 00000000..17e51cbc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantContext.cs @@ -0,0 +1,13 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +public sealed class TenantContext +{ + public TenantKey Tenant { get; } + public bool IsGlobal { get; } + + public TenantContext(TenantKey tenant, bool isGlobal = false) + { + Tenant = tenant; + IsGlobal = isGlobal; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs new file mode 100644 index 00000000..949dc694 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKey.cs @@ -0,0 +1,105 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Security; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +[JsonConverter(typeof(TenantKeyJsonConverter))] +public readonly record struct TenantKey : IParsable +{ + public string Value { get; } + + private TenantKey(string value) + { + Value = value; + } + + private static readonly Regex Allowed = new(@"^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$", RegexOptions.Compiled); + + internal static readonly TenantKey Single = new("__single__"); + internal static readonly TenantKey System = new("__system__"); + internal static readonly TenantKey Unresolved = new("__unresolved__"); + + public bool IsSingle => Value == Single.Value; + public bool IsSystem => Value == System.Value; + public bool IsUnresolved => Value == Unresolved.Value; + + /// + /// True only for real, customer-defined tenants. + /// + public bool IsNormal => !IsSingle && !IsSystem && !IsUnresolved; + + public static TenantKey Parse(string s, IFormatProvider? provider) + { + if (!TryParse(s, provider, out var result)) + throw new FormatException($"Invalid TenantKey value: '{s}'"); + + return result; + } + + public static bool TryParse(string? s, IFormatProvider? provider, out TenantKey result) + { + result = default; + + if (string.IsNullOrWhiteSpace(s)) + return false; + + try + { + result = FromExternal(s); + return true; + } + catch + { + return false; + } + } + + /// + /// Creates a tenant key from EXTERNAL input (HTTP, headers, tokens). + /// System-reserved values are rejected. + /// + public static TenantKey FromExternal(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new SecurityException("Missing tenant claim."); + + var normalized = Normalize(value); + + if (normalized == Single.Value || normalized == System.Value || normalized == Unresolved.Value) + { + throw new ArgumentException("Reserved tenant id."); + } + + return new TenantKey(normalized); + } + + /// + /// Internal creation for framework use only. + /// + internal static TenantKey FromInternal(string value) => new(value); + + private static string Normalize(string value) + { + if (value is null) + throw new ArgumentNullException(nameof(value)); + + var normalized = value.Trim(); + + if (normalized.Length == 0) + throw new ArgumentException("TenantKey cannot be empty."); + + if (normalized.Length > 128) + throw new ArgumentException("TenantKey is too long."); + + if (!Allowed.IsMatch(normalized)) + throw new ArgumentException("TenantKey contains invalid characters."); + + return normalized; + } + + public override string ToString() => Value; + + public static implicit operator string(TenantKey key) => key.Value; +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKeys.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKeys.cs new file mode 100644 index 00000000..7467d4d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantKeys.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +public static class TenantKeys +{ + public static TenantKey Single => TenantKey.Single; + public static TenantKey System => TenantKey.System; +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs new file mode 100644 index 00000000..d5271f0d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionContext.cs @@ -0,0 +1,65 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Represents the normalized request information used during tenant resolution. +/// Resolvers inspect these fields to derive the correct tenant id. +/// +public sealed class TenantResolutionContext +{ + /// + /// The request host value (e.g., "foo.example.com"). + /// Used by HostTenantResolver. + /// + public string? Host { get; init; } + + /// + /// The request path (e.g., "/t/foo/api/..."). + /// Used by PathTenantResolver. + /// + public string? Path { get; init; } + + /// + /// Request headers. Used by HeaderTenantResolver. + /// + public IReadOnlyDictionary? Headers { get; init; } + + /// + /// Query string parameters. Used by future resolvers or custom logic. + /// + public IReadOnlyDictionary? Query { get; init; } + + /// + /// The raw framework-specific request context (e.g., HttpContext). + /// Used only when advanced resolver logic needs full access. + /// RawContext SHOULD NOT be used by built-in resolvers. + /// It exists only for advanced or custom implementations. + /// + public object? RawContext { get; init; } + + /// + /// Gets an empty instance of the TenantResolutionContext class. + /// + /// Use this property to represent a context with no tenant information. This instance + /// can be used as a default or placeholder when no tenant has been resolved. + /// + public static TenantResolutionContext Empty { get; } = new(); + + private TenantResolutionContext() { } + + public static TenantResolutionContext Create( + IReadOnlyDictionary? headers = null, + IReadOnlyDictionary? Query = null, + string? host = null, + string? path = null, + object? rawContext = null) + { + return new TenantResolutionContext + { + Headers = headers, + Query = Query, + Host = host, + Path = path, + RawContext = rawContext + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionResult.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionResult.cs new file mode 100644 index 00000000..f358afa2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/TenantResolutionResult.cs @@ -0,0 +1,23 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +public sealed record TenantResolutionResult +{ + public bool IsResolved { get; } + public TenantKey Tenant { get; } + + private TenantResolutionResult(bool isResolved, TenantKey tenant) + { + IsResolved = isResolved; + Tenant = tenant; + } + + /// + /// Indicates that no tenant could be resolved from the request. + /// + public static TenantResolutionResult NotResolved() => new(isResolved: false, tenant: TenantKey.Unresolved); + + /// + /// Indicates that a tenant has been successfully resolved. + /// + public static TenantResolutionResult Resolved(TenantKey tenant) => new(isResolved: true, tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs new file mode 100644 index 00000000..9f73a2c9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/MultiTenancy/UAuthTenantContext.cs @@ -0,0 +1,25 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.MultiTenancy; + +/// +/// Represents the resolved tenant result for the current request. +/// +public sealed class UAuthTenantContext +{ + public TenantKey Tenant { get; } + + private UAuthTenantContext(TenantKey tenant) + { + if (tenant.IsUnresolved) + throw new InvalidOperationException("Runtime tenant context cannot be unresolved."); + + Tenant = tenant; + } + + public bool IsSingleTenant => Tenant.IsSingle; + public bool IsSystem => Tenant.IsSystem; + + public static UAuthTenantContext SingleTenant() => new(TenantKey.Single); + public static UAuthTenantContext System() => new(TenantKey.System); + public static UAuthTenantContext Unresolved() => new(TenantKey.Unresolved); + public static UAuthTenantContext Resolved(TenantKey tenant) => new(tenant); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/.gitkeep b/src/CodeBeam.UltimateAuth.Core/Options/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Options/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs new file mode 100644 index 00000000..3a8e1265 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/CoreConfigurationIntentDetector.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class CoreConfigurationIntentDetector : IPostConfigureOptions +{ + private readonly DirectCoreConfigurationMarker _marker; + private readonly IConfiguration? _configuration; + + public CoreConfigurationIntentDetector(DirectCoreConfigurationMarker marker, IConfiguration? configuration) + { + _marker = marker; + _configuration = configuration; + } + + public void PostConfigure(string? name, UAuthOptions options) + { + if (_configuration is null) + return; + + var coreSection = _configuration.GetSection("UltimateAuth:Core"); + if (coreSection.Exists()) + { + _marker.MarkConfigured(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs new file mode 100644 index 00000000..826703c8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/HeaderTokenFormat.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +public enum HeaderTokenFormat +{ + Bearer, + Raw +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs b/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs new file mode 100644 index 00000000..65236210 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/IClientProfileDetector.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +public interface IClientProfileDetector +{ + UAuthClientProfile Detect(IServiceProvider services); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs new file mode 100644 index 00000000..65437c18 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/TokenResponseMode.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +public enum TokenResponseMode +{ + None = 0, + Cookie = 10, + Header = 20, + Body = 30 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs new file mode 100644 index 00000000..310a0be2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthClientProfile.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +public enum UAuthClientProfile +{ + NotSpecified = 0, + BlazorWasm = 10, + BlazorServer = 20, + Maui = 30, + WebServer = 40, + Api = 50, + UAuthHub = 100 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs new file mode 100644 index 00000000..4677358e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthLoginOptions.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Options; + +/// +/// Configuration settings related to interactive user login behavior, including lockout policies and failed-attempt thresholds. +/// +public sealed class UAuthLoginOptions +{ + /// + /// Maximum number of consecutive failed login attempts allowed before the user is locked out. + /// Set to 0 to disable lockout entirely. + /// + public int MaxFailedAttempts { get; set; } = 10; + + /// + /// Duration for which the user is locked out after exceeding . + /// + public TimeSpan LockoutDuration { get; set; } = TimeSpan.FromMinutes(15); + + /// + /// Gets or sets a value indicating whether detailed information about authentication failures (like locked until or + /// remaining attempts) is included in responses. + /// + public bool IncludeFailureDetails { get; set; } = true; + + /// + /// Gets or sets the time interval during which failed login attempts are counted for lockout purposes. + /// + /// This property defines the window of time used to evaluate consecutive failed login attempts. + /// If the number of failures within this window exceeds the configured threshold, the account may be locked out. + /// Adjusting this value affects how quickly lockout conditions are triggered. + /// + public TimeSpan FailureWindow { get; set; } = TimeSpan.FromMinutes(15); + + /// + /// Gets or sets a value indicating whether the lock should be extended when a login attempt fails during lockout. + /// + /// Set this property to to automatically extend the lock duration after + /// each failed login attempt. This can help prevent repeated unauthorized access attempts by increasing the lockout period. + /// + public bool ExtendLockOnFailure { get; set; } = false; + + public TimeSpan TryLoginDuration { get; set; } = TimeSpan.FromSeconds(180); + + internal UAuthLoginOptions Clone() => new() + { + MaxFailedAttempts = MaxFailedAttempts, + LockoutDuration = LockoutDuration, + IncludeFailureDetails = IncludeFailureDetails, + FailureWindow = FailureWindow, + ExtendLockOnFailure = ExtendLockOnFailure, + TryLoginDuration = TryLoginDuration + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs new file mode 100644 index 00000000..e9d3533b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMode.cs @@ -0,0 +1,42 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core; + +/// +/// Defines the authentication execution model for UltimateAuth. +/// Each mode represents a fundamentally different security +/// and lifecycle strategy. +/// +public enum UAuthMode +{ + /// + /// Pure opaque, session-based authentication. + /// No JWT, no refresh token. + /// Full server-side control with sliding expiration. + /// Best for Blazor Server, MVC, intranet apps. + /// + PureOpaque = 0, + + /// + /// Full hybrid mode. + /// Session + JWT + refresh token. + /// Server-side session control with JWT performance. + /// Default mode. + /// + Hybrid = 1, + + /// + /// Semi-hybrid mode. + /// JWT is fully stateless at runtime. + /// Session exists only as metadata/control plane + /// (logout, disable, audit, device tracking). + /// No request-time session lookup. + /// + SemiHybrid = 2, + + /// + /// Pure JWT mode. + /// Fully stateless authentication. + /// No session, no server-side lookup. + /// Revocation only via token expiration. + /// + PureJwt = 3 +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs new file mode 100644 index 00000000..ee0b0409 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthMultiTenantOptions.cs @@ -0,0 +1,49 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +// TODO: Add Tenant registration +/// +/// Multi-tenancy configuration for UltimateAuth. +/// Controls whether tenants are required, how they are resolved, +/// and how tenant identifiers are normalized. +/// +public sealed class UAuthMultiTenantOptions +{ + /// + /// Enables multi-tenant mode. + /// When disabled, all requests operate under a single implicit tenant. + /// + public bool Enabled { get; set; } = false; + + ///// + ///// If true, tenant resolution MUST succeed for external requests. + ///// If false, unresolved tenants fall back to single-tenant behavior. + ///// + //public bool RequireTenant { get; set; } = false; + + /// + /// If true, tenant identifiers are normalized to lowercase. + /// Recommended for host-based tenancy. + /// + public bool NormalizeToLowercase { get; set; } = true; + + /// + /// Enables tenant resolution from the URL path and + /// exposes auth endpoints under /{tenant}/{routePrefix}/... + /// + public bool EnableRoute { get; set; } = false; + public bool EnableHeader { get; set; } = false; + public bool EnableDomain { get; set; } = false; + + // Header config + public string HeaderName { get; set; } = "X-Tenant"; + + internal UAuthMultiTenantOptions Clone() => new() + { + Enabled = Enabled, + NormalizeToLowercase = NormalizeToLowercase, + EnableRoute = EnableRoute, + EnableHeader = EnableHeader, + EnableDomain = EnableDomain, + HeaderName = HeaderName + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs new file mode 100644 index 00000000..ac273a50 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptions.cs @@ -0,0 +1,50 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Events; + +namespace CodeBeam.UltimateAuth.Core.Options; + +/// +/// Top-level configuration container for all UltimateAuth features. +/// Combines login policies, session lifecycle rules, token behavior, PKCE settings, multi-tenancy behavior, and user-id normalization. +/// +/// All sub-options are resolved from configuration (appsettings.json) or through inline setup in AddUltimateAuth(). +/// +public sealed class UAuthOptions +{ + public bool AllowDirectCoreConfiguration { get; set; } = false; + + /// + /// Configuration settings for interactive login flows, + /// including lockout thresholds and failed-attempt policies. + /// + public UAuthLoginOptions Login { get; set; } = new(); + + /// + /// Settings that control session creation, refresh behavior, + /// sliding expiration, idle timeouts, device limits, and chain rules. + /// + public UAuthSessionOptions Session { get; set; } = new(); + + /// + /// Token issuance configuration, including JWT and opaque token + /// generation, lifetimes, signing keys, and audience/issuer values. + /// + public UAuthTokenOptions Token { get; set; } = new(); + + /// + /// PKCE (Proof Key for Code Exchange) configuration used for + /// browser-based login flows and WASM authentication. + /// + public UAuthPkceOptions Pkce { get; set; } = new(); + + /// + /// Event hooks raised during authentication lifecycle events + /// such as login, logout, session creation, refresh, or revocation. + /// + public UAuthEvents Events { get; set; } = new(); + + /// + /// Multi-tenancy configuration controlling how tenants are resolved, + /// validated, and optionally enforced. + /// + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs new file mode 100644 index 00000000..c6951f8d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthOptionsPostConfigureGuard.cs @@ -0,0 +1,38 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthOptionsPostConfigureGuard : IPostConfigureOptions +{ + private readonly DirectCoreConfigurationMarker _directConfigMarker; + private readonly IEnumerable _runtimeMarkers; + + public UAuthOptionsPostConfigureGuard(DirectCoreConfigurationMarker directConfigMarker, IEnumerable runtimeMarkers) + { + _directConfigMarker = directConfigMarker; + _runtimeMarkers = runtimeMarkers; + } + + public void PostConfigure(string? name, UAuthOptions options) + { + var hasServerRuntime = _runtimeMarkers.Any(); + + if (!_directConfigMarker.IsConfigured) + { + return; + } + + if (hasServerRuntime) + { + throw new InvalidOperationException("Direct core configuration is not allowed in server-hosted applications. " + + "Configure authentication policies via AddUltimateAuthServer instead."); + } + + if (!options.AllowDirectCoreConfiguration) + { + throw new InvalidOperationException("Direct core configuration is not allowed. " + + "Set AllowDirectCoreConfiguration = true only for advanced, non-server scenarios."); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs new file mode 100644 index 00000000..6549acc4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthPkceOptions.cs @@ -0,0 +1,21 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +/// +/// Configuration settings for PKCE (Proof Key for Code Exchange) +/// authorization flows. Controls how long authorization codes remain +/// valid before they must be exchanged for tokens. +/// +public sealed class UAuthPkceOptions +{ + /// + /// Lifetime of a PKCE authorization code in seconds. + /// Shorter values provide stronger replay protection, + /// while longer values allow more tolerance for slow clients. + /// + public int AuthorizationCodeLifetimeSeconds { get; set; } = 120; + + internal UAuthPkceOptions Clone() => new() + { + AuthorizationCodeLifetimeSeconds = AuthorizationCodeLifetimeSeconds + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs new file mode 100644 index 00000000..1514da03 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthSessionOptions.cs @@ -0,0 +1,138 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Core.Options; + +// TODO: Add rotate on refresh (especially for Hybrid). Default behavior should be single session in chain for Hybrid, but can be configured. +// And add RotateAsync method. +// Implement all options. + +/// +/// Defines configuration settings that control the lifecycle, security behavior, and device constraints of UltimateAuth +/// session management. +/// +/// These values influence how sessions are created, refreshed, expired, revoked, and grouped into device chains. +/// +public sealed class UAuthSessionOptions +{ + /// + /// The standard lifetime of a session before it expires. + /// This is the duration added during login or refresh. + /// + public TimeSpan Lifetime { get; set; } = TimeSpan.FromDays(7); + + /// + /// Maximum absolute lifetime a session may have, even when + /// sliding expiration is enabled. If null, no hard cap + /// is applied. + /// + public TimeSpan? MaxLifetime { get; set; } + + /// + /// When enabled, each refresh extends the session's expiration, + /// allowing continuous usage until MaxLifetime or idle rules apply. + /// On PureOpaque (or one token usage) mode, each touch restarts the session lifetime. + /// + public bool SlidingExpiration { get; set; } = true; + + /// + /// Maximum allowed idle time before the session becomes invalid. + /// If null or zero, idle expiration is disabled entirely. + /// + public TimeSpan? IdleTimeout { get; set; } + + /// + /// Minimum interval between LastSeenAt updates. + /// Prevents excessive writes under high traffic. + /// + public TimeSpan? TouchInterval { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Maximum number of device session chains a single user may have. + /// Set to zero to indicate no user-level chain limit. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public int MaxChainsPerUser { get; set; } = 0; + + /// + /// Maximum number of session rotations within a single chain. + /// Used for cleanup, replay protection, and analytics. + /// + public int MaxSessionsPerChain { get; set; } = 100; + + /// + /// Optional limit on the number of session chains allowed per platform + /// (e.g. "web" = 1, "mobile" = 1). + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public Dictionary? MaxChainsPerPlatform { get; set; } + + /// + /// Defines platform categories that map multiple platforms + /// into a single abstract group (e.g. mobile: [ "ios", "android", "tablet" ]). + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public Dictionary? PlatformCategories { get; set; } + + /// + /// Limits how many session chains can exist per platform category + /// (e.g. mobile = 1, desktop = 2). + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public Dictionary? MaxChainsPerCategory { get; set; } + + /// + /// Enables binding sessions to the user's IP address. + /// When enabled, IP mismatches can invalidate a session. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public bool EnableIpBinding { get; set; } = false; + + /// + /// Enables binding sessions to the user's User-Agent header. + /// When enabled, UA mismatches can invalidate a session. + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public bool EnableUserAgentBinding { get; set; } = false; + + /// + /// NOTE: + /// Enforcement is not active in v0.0.1. + /// This option is reserved for future security policies. + /// + public DeviceMismatchBehavior DeviceMismatchBehavior { get; set; } = DeviceMismatchBehavior.Reject; + + internal UAuthSessionOptions Clone() => new() + { + SlidingExpiration = SlidingExpiration, + IdleTimeout = IdleTimeout, + Lifetime = Lifetime, + MaxLifetime = MaxLifetime, + TouchInterval = TouchInterval, + DeviceMismatchBehavior = DeviceMismatchBehavior, + MaxChainsPerUser = MaxChainsPerUser, + MaxSessionsPerChain = MaxSessionsPerChain, + MaxChainsPerPlatform = MaxChainsPerPlatform is null ? null : new Dictionary(MaxChainsPerPlatform), + MaxChainsPerCategory = MaxChainsPerCategory is null ? null : new Dictionary(MaxChainsPerCategory), + PlatformCategories = PlatformCategories is null ? null : PlatformCategories.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.ToArray()), + EnableIpBinding = EnableIpBinding, + EnableUserAgentBinding = EnableUserAgentBinding + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs new file mode 100644 index 00000000..7d9aeacd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/UAuthTokenOptions.cs @@ -0,0 +1,78 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Options; + +/// +/// Configuration settings for access and refresh token behavior +/// within UltimateAuth. Includes JWT and opaque token generation, +/// lifetimes, and cryptographic settings. +/// +public sealed class UAuthTokenOptions +{ + /// + /// Determines whether JWT-format access tokens should be issued. + /// Recommended for APIs that rely on claims-based authorization. + /// + public bool IssueJwt { get; set; } = true; + + /// + /// Determines whether opaque tokens (session-id based) should be issued. + /// Useful for high-security APIs where token introspection is required. + /// + public bool IssueOpaque { get; set; } = true; + + public bool IssueRefresh { get; set; } = true; + + /// + /// Lifetime of access tokens (JWT or opaque). + /// Short lifetimes improve security but require more frequent refreshes. + /// + public TimeSpan AccessTokenLifetime { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Lifetime of refresh tokens, used in PKCE or session rotation flows. + /// + public TimeSpan RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(7); + + /// + /// Number of bytes of randomness used when generating opaque token IDs. + /// Larger values increase entropy and reduce collision risk. + /// + public int OpaqueIdBytes { get; set; } = 32; + + /// + /// Value assigned to the JWT "iss" (issuer) claim. + /// Identifies the authority that issued the token. + /// + public string Issuer { get; set; } = "UAuth"; + + /// + /// Value assigned to the JWT "aud" (audience) claim. + /// Controls which clients or APIs are permitted to consume the token. + /// + public string Audience { get; set; } = "UAuthClient"; + + /// + /// If true, adds a unique 'jti' (JWT ID) claim to each issued JWT. + /// Useful for token replay detection and advanced auditing. + /// + public bool AddJwtIdClaim { get; set; } = false; + + /// + /// Optional key identifier to select signing key. + /// If null, default key will be used. + /// + public string? KeyId { get; set; } + + internal UAuthTokenOptions Clone() => new() + { + IssueOpaque = IssueOpaque, + IssueJwt = IssueJwt, + IssueRefresh = IssueRefresh, + AccessTokenLifetime = AccessTokenLifetime, + RefreshTokenLifetime = RefreshTokenLifetime, + OpaqueIdBytes = OpaqueIdBytes, + Issuer = Issuer, + Audience = Audience, + AddJwtIdClaim = AddJwtIdClaim, + KeyId = KeyId + }; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs new file mode 100644 index 00000000..727dcd1c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthLoginOptionsValidator.cs @@ -0,0 +1,24 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthLoginOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthLoginOptions options) + { + var errors = new List(); + + if (options.MaxFailedAttempts < 0) + errors.Add("Login.MaxFailedAttempts cannot be negative."); + + if (options.MaxFailedAttempts > 100) + errors.Add("Login.MaxFailedAttempts cannot exceed 100. Use 0 to disable lockout."); + + if (options.MaxFailedAttempts > 0 && options.LockoutDuration <= TimeSpan.Zero) + errors.Add("Login.LockoutMinutes must be greater than zero when lockout is enabled."); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs new file mode 100644 index 00000000..762564d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthMultiTenantOptionsValidator.cs @@ -0,0 +1,42 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthMultiTenantOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthMultiTenantOptions options) + { + var errors = new List(); + + if (!options.Enabled) + { + if (options.EnableRoute || options.EnableHeader || options.EnableDomain) + { + errors.Add("Multi-tenancy is disabled, but one or more tenant resolvers are enabled. " + + "Either enable multi-tenancy or disable all tenant resolvers."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + if (!options.EnableRoute && !options.EnableHeader && !options.EnableDomain) + { + errors.Add("Multi-tenancy is enabled but no tenant resolver is active. " + + "Enable at least one of: route, header or domain."); + } + + if (options.EnableHeader) + { + if (string.IsNullOrWhiteSpace(options.HeaderName)) + { + errors.Add("MultiTenant.HeaderName must be specified when header-based tenant resolution is enabled."); + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs new file mode 100644 index 00000000..12119bb6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthOptionsValidator.cs @@ -0,0 +1,49 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthOptions options) + { + var errors = new List(); + + + if (options.Login is null) + errors.Add("UltimateAuth.Login configuration section is missing."); + + if (options.Session is null) + errors.Add("UltimateAuth.Session configuration section is missing."); + + if (options.Token is null) + errors.Add("UltimateAuth.Token configuration section is missing."); + + if (options.Pkce is null) + errors.Add("UltimateAuth.Pkce configuration section is missing."); + + if (options.Events is null) + errors.Add("UltimateAuth.Events configuration section is missing."); + + if (options.MultiTenant is null) + errors.Add("UltimateAuth.MultiTenant configuration section is missing."); + + if (errors.Count > 0) + return ValidateOptionsResult.Fail(errors); + + + // Only add cross-option validation beyond this point, individual options should validate in their own validators. + if (options.Token!.AccessTokenLifetime > options.Session!.MaxLifetime) + { + errors.Add("Token.AccessTokenLifetime cannot exceed Session.MaxLifetime."); + } + + if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime) + { + errors.Add("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs new file mode 100644 index 00000000..4ee9c2ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthPkceOptionsValidator.cs @@ -0,0 +1,20 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthPkceOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthPkceOptions options) + { + var errors = new List(); + + if (options.AuthorizationCodeLifetimeSeconds <= 0) + { + errors.Add("Pkce.AuthorizationCodeLifetimeSeconds must be > 0."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs new file mode 100644 index 00000000..657def36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthSessionOptionsValidator.cs @@ -0,0 +1,97 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthSessionOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthSessionOptions options) + { + var errors = new List(); + + if (options.Lifetime <= TimeSpan.Zero) + errors.Add("Session.Lifetime must be greater than zero."); + + if (options.MaxLifetime < options.Lifetime) + errors.Add("Session.MaxLifetime must be greater than or equal to Session.Lifetime."); + + if (options.IdleTimeout.HasValue && options.IdleTimeout < TimeSpan.Zero) + errors.Add("Session.IdleTimeout cannot be negative."); + + if (options.IdleTimeout.HasValue && options.IdleTimeout > TimeSpan.Zero && options.IdleTimeout > options.MaxLifetime) + { + errors.Add("Session.IdleTimeout cannot exceed Session.MaxLifetime."); + } + + //if (options.MaxChainsPerUser <= 0) + // errors.Add("Session.MaxChainsPerUser must be at least 1."); + + //if (options.MaxSessionsPerChain <= 0) + // errors.Add("Session.MaxSessionsPerChain must be at least 1."); + + //if (options.MaxChainsPerPlatform != null) + //{ + // foreach (var kv in options.MaxChainsPerPlatform) + // { + // if (string.IsNullOrWhiteSpace(kv.Key)) + // errors.Add("Session.MaxChainsPerPlatform contains an empty platform key."); + + // if (kv.Value <= 0) + // errors.Add($"Session.MaxChainsPerPlatform['{kv.Key}'] must be >= 1."); + // } + //} + + //if (options.PlatformCategories != null) + //{ + // foreach (var cat in options.PlatformCategories) + // { + // var categoryName = cat.Key; + // var platforms = cat.Value; + + // if (string.IsNullOrWhiteSpace(categoryName)) + // errors.Add("Session.PlatformCategories contains an empty category name."); + + // if (platforms == null || platforms.Length == 0) + // errors.Add($"Session.PlatformCategories['{categoryName}'] must contain at least one platform."); + + // var duplicates = platforms? + // .GroupBy(p => p) + // .Where(g => g.Count() > 1) + // .Select(g => g.Key); + // if (duplicates?.Any() == true) + // { + // errors.Add($"Session.PlatformCategories['{categoryName}'] contains duplicate platforms: {string.Join(", ", duplicates)}"); + // } + // } + //} + + //if (options.MaxChainsPerCategory != null) + //{ + // foreach (var kv in options.MaxChainsPerCategory) + // { + // if (string.IsNullOrWhiteSpace(kv.Key)) + // errors.Add("Session.MaxChainsPerCategory contains an empty category key."); + + // if (kv.Value <= 0) + // errors.Add($"Session.MaxChainsPerCategory['{kv.Key}'] must be >= 1."); + // } + //} + + //if (options.PlatformCategories != null && options.MaxChainsPerCategory != null) + //{ + // foreach (var category in options.PlatformCategories.Keys) + // { + // if (!options.MaxChainsPerCategory.ContainsKey(category)) + // { + // errors.Add( + // $"Session.MaxChainsPerCategory must define a limit for category '{category}' " + + // "because it exists in Session.PlatformCategories."); + // } + // } + //} + + if (errors.Count == 0) + return ValidateOptionsResult.Success; + + return ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs new file mode 100644 index 00000000..33881151 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Options/Validators/UAuthTokenOptionsValidator.cs @@ -0,0 +1,48 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthTokenOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthTokenOptions options) + { + var errors = new List(); + + if (!options.IssueJwt && !options.IssueOpaque) + errors.Add("Token: At least one of IssueJwt or IssueOpaque must be enabled."); + + if (options.AccessTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.AccessTokenLifetime must be greater than zero."); + + if (options.IssueRefresh) + { + if (options.RefreshTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.RefreshTokenLifetime must be greater than zero when IssueRefresh is enabled."); + + if (options.RefreshTokenLifetime <= options.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + } + + if (options.IssueJwt) + { + if (string.IsNullOrWhiteSpace(options.Issuer) || options.Issuer.Trim().Length < 3) + errors.Add("Token.Issuer must be at least 3 characters when IssueJwt is enabled."); + + if (string.IsNullOrWhiteSpace(options.Audience) || options.Audience.Trim().Length < 3) + errors.Add("Token.Audience must be at least 3 characters when IssueJwt is enabled."); + } + + if (options.IssueOpaque) + { + if (options.OpaqueIdBytes < 16) + errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + + if (options.OpaqueIdBytes > 128) + errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/README.md b/src/CodeBeam.UltimateAuth.Core/README.md new file mode 100644 index 00000000..47472f1f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/README.md @@ -0,0 +1,14 @@ +๏ปฟ# UltimateAuth Core + +Core domain primitives and abstractions for UltimateAuth. + +โš ๏ธ This package is not intended to be installed directly in most applications. + +## Use instead + +- CodeBeam.UltimateAuth.Server (Main backend package) +- CodeBeam.UltimateAuth.Client (Main client package) + +This package is included transitively by higher-level UltimateAuth packages. + +###### Look at https://github.com/CodeBeamOrg/UltimateAuth for installation details. \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs new file mode 100644 index 00000000..c65c789d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/DirectCoreConfigurationMarker.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Runtime; + +/// +/// Internal marker indicating that UAuthOptions +/// were configured directly by the application. +/// +internal sealed class DirectCoreConfigurationMarker +{ + public bool IsConfigured { get; private set; } + + public void MarkConfigured() + { + IsConfigured = true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs new file mode 100644 index 00000000..a8d869d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthHubMarker.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Runtime; + +/// +/// Marker interface indicating that the current application +/// hosts an UltimateAuth Hub. +/// +public interface IUAuthHubMarker +{ + bool RequiresCors { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs new file mode 100644 index 00000000..d5238bce --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthProductInfoProvider.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Runtime; + +public interface IUAuthProductInfoProvider +{ + UAuthProductInfo Get(); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs new file mode 100644 index 00000000..9e90c8d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/IUAuthRuntimeMarker.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Runtime; + +/// +/// Marker interface indicating that UltimateAuth is running in a specific runtime context (e.g. server-hosted). +/// Implementations must be provided by integration layers such as UltimateAuth.Server. +/// +public interface IUAuthRuntimeMarker +{ +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs new file mode 100644 index 00000000..7e4b3841 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfo.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Core.Runtime; + +public sealed class UAuthProductInfo +{ + public string ProductName { get; init; } = "UltimateAuth"; + public string Version { get; init; } = default!; + public string? InformationalVersion { get; init; } + + public DateTimeOffset StartedAt { get; init; } + public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); +} diff --git a/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs new file mode 100644 index 00000000..99f027c2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Core/Runtime/UAuthProductInfoProvider.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Options; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Core.Runtime; + +internal sealed class UAuthProductInfoProvider : IUAuthProductInfoProvider +{ + private readonly UAuthProductInfo _info; + + public UAuthProductInfoProvider(IOptions options) + { + var asm = typeof(UAuthProductInfoProvider).Assembly; + + _info = new UAuthProductInfo + { + Version = asm.GetName().Version?.ToString(3) ?? "unknown", + InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, + StartedAt = DateTimeOffset.UtcNow + }; + } + + public UAuthProductInfo Get() => _info; +} diff --git a/src/CodeBeam.UltimateAuth.Core/Security/.gitkeep b/src/CodeBeam.UltimateAuth.Core/Security/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Core/Security/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Core/logo.png b/src/CodeBeam.UltimateAuth.Core/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/CodeBeam.UltimateAuth.Core/logo.png differ diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs new file mode 100644 index 00000000..51be3f4b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ICredentialResponseWriter.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface ICredentialResponseWriter +{ + void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId); + void Write(HttpContext context, GrantKind kind, AccessToken accessToken); + void Write(HttpContext context, GrantKind kind, RefreshTokenInfo refreshToken); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs new file mode 100644 index 00000000..595c51a9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IDeviceResolver.cs @@ -0,0 +1,12 @@ +๏ปฟusing Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +/// +/// Resolves device and client metadata from the current HTTP context. +/// +public interface IDeviceResolver +{ + Task ResolveAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs new file mode 100644 index 00000000..d4bfbfca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IPrimaryCredentialResolver.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface IPrimaryCredentialResolver +{ + PrimaryGrantKind Resolve(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs new file mode 100644 index 00000000..237668d9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/IRefreshTokenResolver.cs @@ -0,0 +1,12 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface IRefreshTokenResolver +{ + /// + /// Resolves refresh token from incoming HTTP request. + /// Returns null if no refresh token is present. + /// + string? Resolve(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs new file mode 100644 index 00000000..189b43ac --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ISigningKeyProvider.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Abstractions; + +public interface IJwtSigningKeyProvider +{ + JwtSigningKey Resolve(string? keyId); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs new file mode 100644 index 00000000..9b525f5f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Abstractions/ITokenIssuer.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Abstactions; + +/// +/// Issues access and refresh tokens according to the active auth mode. +/// Does not perform persistence or validation. +/// +public interface ITokenIssuer +{ + Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken cancellationToken = default); + Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken cancellationToken = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/AuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/AuthFlowContextAccessor.cs new file mode 100644 index 00000000..005c16eb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/AuthFlowContextAccessor.cs @@ -0,0 +1,35 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthFlowContextAccessor : IAuthFlowContextAccessor +{ + private static readonly object Key = new(); + + private readonly IHttpContextAccessor _http; + + public AuthFlowContextAccessor(IHttpContextAccessor http) + { + _http = http; + } + + public AuthFlowContext Current + { + get + { + var ctx = _http.HttpContext + ?? throw new InvalidOperationException("No HttpContext."); + + if (!ctx.Items.TryGetValue(Key, out var value) || value is not AuthFlowContext flow) + throw new InvalidOperationException("AuthFlowContext is not available for this request."); + + return flow; + } + } + + internal void Set(AuthFlowContext context) + { + var ctx = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext."); + ctx.Items[Key] = context; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs new file mode 100644 index 00000000..6be75e85 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Accessor/IAuthFlowContextAccessor.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthFlowContextAccessor +{ + AuthFlowContext Current { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs new file mode 100644 index 00000000..76e977f3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/AuthStateSnapshotFactory.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users; + +namespace CodeBeam.UltimateAuth.Server.Auth +{ + internal sealed class AuthStateSnapshotFactory : IAuthStateSnapshotFactory + { + private readonly IPrimaryUserIdentifierProvider _identifier; + private readonly IUserProfileSnapshotProvider _profile; + private readonly IUserLifecycleSnapshotProvider _lifecycle; + + public AuthStateSnapshotFactory(IPrimaryUserIdentifierProvider identifier, IUserProfileSnapshotProvider profile, IUserLifecycleSnapshotProvider lifecycle) + { + _identifier = identifier; + _profile = profile; + _lifecycle = lifecycle; + } + + public async Task CreateAsync(SessionValidationResult validation, CancellationToken ct = default) + { + if (!validation.IsValid || validation.UserKey is null) + return null; + + var identifiers = await _identifier.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var profile = await _profile.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + var lifecycle = await _lifecycle.GetAsync(validation.Tenant, validation.UserKey.Value, ct); + + var identity = new AuthIdentitySnapshot + { + UserKey = validation.UserKey.Value, + Tenant = validation.Tenant, + PrimaryUserName = identifiers?.UserName, + PrimaryEmail = identifiers?.Email, + PrimaryPhone = identifiers?.Phone, + DisplayName = profile?.DisplayName, + TimeZone = profile?.TimeZone, + AuthenticatedAt = validation.AuthenticatedAt, + SessionState = validation.State, + UserStatus = lifecycle?.Status ?? UserStatus.Unknown + }; + + return new AuthStateSnapshot + { + Identity = identity, + Claims = validation.Claims + }; + } + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs new file mode 100644 index 00000000..92fe58c1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/ClientProfileReader.cs @@ -0,0 +1,37 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class ClientProfileReader : IClientProfileReader +{ + public async Task ReadAsync(HttpContext context) + { + if (context.Request.Headers.TryGetValue(UAuthConstants.Headers.ClientProfile, out var headerValue) && TryParse(headerValue, out var headerProfile)) + { + return headerProfile; + } + + var form = await context.GetCachedFormAsync(); + + if (form is not null && form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) && TryParse(formValue, out var formProfile)) + { + return formProfile; + } + + //if (context.Request.HasFormContentType && context.Request.Form.TryGetValue(UAuthConstants.Form.ClientProfile, out var formValue) && + // TryParse(formValue, out var formProfile)) + //{ + // return formProfile; + //} + + return UAuthClientProfile.NotSpecified; + } + + private static bool TryParse(string? value, out UAuthClientProfile profile) + { + return Enum.TryParse(value, ignoreCase: true, out profile); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs new file mode 100644 index 00000000..762e02b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AccessContextFactory.cs @@ -0,0 +1,83 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.ObjectModel; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AccessContextFactory : IAccessContextFactory +{ + private readonly IUserRoleStoreFactory _userRoleFactory; + private readonly IUserIdConverterResolver _converterResolver; + + public AccessContextFactory(IUserRoleStoreFactory userRoleFactory, IUserIdConverterResolver converterResolver) + { + _userRoleFactory = userRoleFactory; + _converterResolver = converterResolver; + } + + public async Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, IDictionary? attributes = null, CancellationToken ct = default) + { + return await CreateInternalAsync(authFlow, action, resource, authFlow.Tenant, resourceId, attributes, ct); + } + + public async Task CreateForExplicitTenantResourceAsync(AuthFlowContext authFlow, string action, string resource, TenantKey resourceTenant, string? resourceId = null, IDictionary? attributes = null, CancellationToken ct = default) + { + if (resourceTenant.IsSystem || resourceTenant.IsUnresolved) + throw new InvalidOperationException("Invalid resource tenant."); + + return await CreateInternalAsync(authFlow, action, resource, resourceTenant, resourceId, attributes, ct); + } + + private async Task CreateInternalAsync(AuthFlowContext authFlow, string action, string resource, TenantKey resourceTenant, string? resourceId, IDictionary? attributes, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(action)) + throw new ArgumentException("Action is required.", nameof(action)); + + if (string.IsNullOrWhiteSpace(resource)) + throw new ArgumentException("Resource is required.", nameof(resource)); + + var attrs = attributes != null + ? new Dictionary(attributes) + : new Dictionary(); + + if (authFlow.IsAuthenticated && authFlow.UserKey is not null) + { + var userRoleStore = _userRoleFactory.Create(authFlow.Tenant); + var assignments = await userRoleStore.GetAssignmentsAsync(authFlow.UserKey.Value, ct); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + + attrs["roles"] = roleIds; + } + + UserKey? targetUserKey = null; + + if (!string.IsNullOrWhiteSpace(resourceId)) + { + var converter = _converterResolver.GetConverter(null); + + if (!converter.TryFromString(resourceId, out var parsed)) + throw new InvalidOperationException("Invalid resource user id."); + + var canonical = converter.ToCanonicalString(parsed); + targetUserKey = UserKey.FromString(canonical); + } + + return new AccessContext( + actorUserKey: authFlow.UserKey, + actorTenant: authFlow.Tenant, + isAuthenticated: authFlow.IsAuthenticated, + isSystemActor: authFlow.Tenant.IsSystem, + actorChainId: authFlow.Session?.ChainId, + resource: resource, + targetUserKey: targetUserKey, + resourceTenant: resourceTenant, + action: action, + attributes: attrs.Count > 0 + ? new ReadOnlyDictionary(attrs) + : EmptyAttributes.Instance + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs new file mode 100644 index 00000000..948cfc8f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthContextFactory.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthContextFactory : IAuthContextFactory +{ + private readonly IAuthFlowContextAccessor _flow; + private readonly IClock _clock; + + public AuthContextFactory(IAuthFlowContextAccessor flow, IClock clock) + { + _flow = flow; + _clock = clock; + } + + public AuthContext Create(DateTimeOffset? at = null) + { + var flow = _flow.Current; + return flow.ToAuthContext(at ?? _clock.UtcNow); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs new file mode 100644 index 00000000..74abb271 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthExecutionContext.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed record AuthExecutionContext +{ + public required UAuthClientProfile? EffectiveClientProfile { get; init; } + public DeviceContext? Device { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlow.cs new file mode 100644 index 00000000..c45d509c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlow.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthFlow : IAuthFlow +{ + private readonly IHttpContextAccessor _http; + private readonly IAuthFlowContextFactory _factory; + private readonly AuthFlowContextAccessor _accessor; + + public AuthFlow(IHttpContextAccessor http, IAuthFlowContextFactory factory, IAuthFlowContextAccessor accessor) + { + _http = http; + _factory = factory; + _accessor = (AuthFlowContextAccessor)accessor; + } + + public async ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default) + { + var ctx = _http.HttpContext ?? throw new InvalidOperationException("No HttpContext."); + + var flowContext = await _factory.CreateAsync(ctx, flowType); + _accessor.Set(flowContext); + + return flowContext; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs new file mode 100644 index 00000000..50bd4e05 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContext.cs @@ -0,0 +1,76 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class AuthFlowContext +{ + + public AuthFlowType FlowType { get; } + public UAuthClientProfile ClientProfile { get; } + public UAuthMode EffectiveMode { get; } + public DeviceContext Device { get; } + + public TenantKey Tenant { get; } + public SessionSecurityContext? Session { get; } + public bool IsAuthenticated { get; } + public UserKey? UserKey { get; } + + public UAuthServerOptions OriginalOptions { get; } + public EffectiveUAuthServerOptions EffectiveOptions { get; } + + public EffectiveAuthResponse Response { get; } + public ReturnUrlInfo ReturnUrlInfo { get; } + + public PrimaryTokenKind PrimaryTokenKind { get; } + + // Helpers + public bool AllowsTokenIssuance => + Response.AccessTokenDelivery.Mode != TokenResponseMode.None || + Response.RefreshTokenDelivery.Mode != TokenResponseMode.None; + + public bool IsSingleTenant => Tenant.IsSingle; + public bool IsMultiTenant => !Tenant.IsSingle && !Tenant.IsSystem; + + internal AuthFlowContext( + AuthFlowType flowType, + UAuthClientProfile clientProfile, + UAuthMode effectiveMode, + DeviceContext device, + TenantKey tenantKey, + bool isAuthenticated, + UserKey? userKey, + SessionSecurityContext? session, + UAuthServerOptions originalOptions, + EffectiveUAuthServerOptions effectiveOptions, + EffectiveAuthResponse response, + PrimaryTokenKind primaryTokenKind, + ReturnUrlInfo returnUrlInfo) + { + if (tenantKey.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); + + FlowType = flowType; + ClientProfile = clientProfile; + EffectiveMode = effectiveMode; + Device = device; + + Tenant = tenantKey; + Session = session; + IsAuthenticated = isAuthenticated; + UserKey = userKey; + + OriginalOptions = originalOptions; + EffectiveOptions = effectiveOptions; + + Response = response; + PrimaryTokenKind = primaryTokenKind; + + ReturnUrlInfo = returnUrlInfo; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs new file mode 100644 index 00000000..3357fd43 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowContextFactory.cs @@ -0,0 +1,171 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthFlowContextFactory : IAuthFlowContextFactory +{ + private readonly IClientProfileReader _clientProfileReader; + private readonly IPrimaryTokenResolver _primaryTokenResolver; + private readonly IEffectiveServerOptionsProvider _serverOptionsProvider; + private readonly IAuthResponseResolver _authResponseResolver; + private readonly IDeviceResolver _deviceResolver; + private readonly IDeviceContextFactory _deviceContextFactory; + private readonly ISessionValidator _sessionValidator; + private readonly IClock _clock; + + public AuthFlowContextFactory( + IClientProfileReader clientProfileReader, + IPrimaryTokenResolver primaryTokenResolver, + IEffectiveServerOptionsProvider serverOptionsProvider, + IAuthResponseResolver authResponseResolver, + IDeviceResolver deviceResolver, + IDeviceContextFactory deviceContextFactory, + ISessionValidator sessionValidator, + IClock clock) + { + _clientProfileReader = clientProfileReader; + _primaryTokenResolver = primaryTokenResolver; + _serverOptionsProvider = serverOptionsProvider; + _authResponseResolver = authResponseResolver; + _deviceResolver = deviceResolver; + _deviceContextFactory = deviceContextFactory; + _sessionValidator = sessionValidator; + _clock = clock; + } + + public async ValueTask CreateAsync(HttpContext ctx, AuthFlowType flowType, CancellationToken ct = default) + { + var tenant = ctx.GetTenant(); + var sessionCtx = ctx.GetSessionContext(); + var user = ctx.GetUserContext(); + + var clientProfile = await _clientProfileReader.ReadAsync(ctx); + var originalOptions = _serverOptionsProvider.GetOriginal(ctx); + var effectiveOptions = _serverOptionsProvider.GetEffective(ctx, flowType, clientProfile); + + var allowedModes = originalOptions.AllowedModes; + + if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveOptions.Mode)) + { + throw new InvalidOperationException($"Auth mode '{effectiveOptions.Mode}' is not allowed by server configuration."); + } + + var effectiveMode = effectiveOptions.Mode; + var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); + var response = _authResponseResolver.Resolve(effectiveMode, flowType, clientProfile, effectiveOptions); + var deviceInfo = await _deviceResolver.ResolveAsync(ctx); + var deviceContext = _deviceContextFactory.Create(deviceInfo); + var returnUrl = await ctx.GetReturnUrlAsync(); + var returnUrlInfo = ReturnUrlParser.Parse(returnUrl); + + SessionSecurityContext? sessionSecurityContext = null; + + if (!sessionCtx.IsAnonymous) + { + var validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = tenant, + SessionId = sessionCtx.SessionId!.Value, + Device = deviceContext, + Now = _clock.UtcNow + }, + ct); + + sessionSecurityContext = SessionValidationMapper.ToSecurityContext(validation); + } + + if (tenant.IsUnresolved) + throw new InvalidOperationException("AuthFlowContext cannot be created with unresolved tenant."); + + // TODO: Implement invariant checker + //_invariantChecker.Validate(flowType, effectiveMode, response, effectiveOptions); + + return new AuthFlowContext( + flowType, + clientProfile, + effectiveMode, + deviceContext, + tenant, + user?.IsAuthenticated ?? false, + user?.UserId, + sessionSecurityContext, + originalOptions, + effectiveOptions, + response, + primaryTokenKind, + returnUrlInfo + ); + } + + public async ValueTask RecreateWithClientProfileAsync(AuthFlowContext existing, UAuthClientProfile overriddenProfile, CancellationToken ct = default) + { + var flowType = existing.FlowType; + var tenant = existing.Tenant; + + var originalOptions = existing.OriginalOptions; + var effectiveOptions = _serverOptionsProvider.GetEffective(tenant, flowType, overriddenProfile); + + var allowedModes = originalOptions.AllowedModes; + + if (allowedModes is { Count: > 0 } && !allowedModes.Contains(effectiveOptions.Mode)) + { + throw new InvalidOperationException($"Auth mode '{effectiveOptions.Mode}' is not allowed by server configuration."); + } + + var effectiveMode = effectiveOptions.Mode; + var primaryTokenKind = _primaryTokenResolver.Resolve(effectiveMode); + var response = _authResponseResolver.Resolve(effectiveMode, flowType, overriddenProfile, effectiveOptions); + + var returnUrlInfo = existing.ReturnUrlInfo; + var deviceContext = existing.Device; + var session = existing.Session; + + return new AuthFlowContext( + flowType, + overriddenProfile, + effectiveMode, + deviceContext, + tenant, + existing.IsAuthenticated, + existing.UserKey, + session, + originalOptions, + effectiveOptions, + response, + primaryTokenKind, + returnUrlInfo + ); + } + + public ValueTask RecreateWithDeviceAsync(AuthFlowContext existing, DeviceContext device, CancellationToken ct = default) + { + var flowType = existing.FlowType; + var tenant = existing.Tenant; + + return ValueTask.FromResult(new AuthFlowContext( + flowType, + existing.ClientProfile, + existing.EffectiveMode, + device, + tenant, + existing.IsAuthenticated, + existing.UserKey, + existing.Session, + existing.OriginalOptions, + existing.EffectiveOptions, + existing.Response, + existing.PrimaryTokenKind, + existing.ReturnUrlInfo + )); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs new file mode 100644 index 00000000..2c6304d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowEndpointFilter.cs @@ -0,0 +1,24 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthFlowEndpointFilter : IEndpointFilter +{ + private readonly IAuthFlow _authFlow; + + public AuthFlowEndpointFilter(IAuthFlow authFlow) + { + _authFlow = authFlow; + } + + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + var metadata = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata(); + + if (metadata != null) + { + await _authFlow.BeginAsync(metadata.FlowType); + } + return await next(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs new file mode 100644 index 00000000..f02bdbfe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/AuthFlowMetadata.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class AuthFlowMetadata +{ + public AuthFlowType FlowType { get; } + + public AuthFlowMetadata(AuthFlowType flowType) + { + FlowType = flowType; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs new file mode 100644 index 00000000..7c6a5fe2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAccessContextFactory.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAccessContextFactory +{ + Task CreateAsync(AuthFlowContext authFlow, string action, string resource, string? resourceId = null, IDictionary? attributes = null, CancellationToken ct = default); + Task CreateForExplicitTenantResourceAsync(AuthFlowContext authFlow, string action, string resource, TenantKey resourceTenant, string? resourceId = null, IDictionary? attributes = null, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs new file mode 100644 index 00000000..1584acb0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlow.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthFlow +{ + ValueTask BeginAsync(AuthFlowType flowType, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs new file mode 100644 index 00000000..ecb7e738 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Context/IAuthFlowContextFactory.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthFlowContextFactory +{ + ValueTask CreateAsync(HttpContext httpContext, AuthFlowType flowType, CancellationToken ct = default); + ValueTask RecreateWithClientProfileAsync(AuthFlowContext existing, UAuthClientProfile overriddenProfile, CancellationToken ct = default); + ValueTask RecreateWithDeviceAsync(AuthFlowContext existing, DeviceContext device, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs new file mode 100644 index 00000000..4d3052ca --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveServerOptionsProvider.cs @@ -0,0 +1,52 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class EffectiveServerOptionsProvider : IEffectiveServerOptionsProvider +{ + private readonly IOptions _baseOptions; + private readonly IEffectiveAuthModeResolver _modeResolver; + + public EffectiveServerOptionsProvider(IOptions baseOptions, IEffectiveAuthModeResolver modeResolver) + { + _baseOptions = baseOptions; + _modeResolver = modeResolver; + } + + public UAuthServerOptions GetOriginal(HttpContext context) + { + return _baseOptions.Value; + } + + public EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile) + { + var tenant = context.GetTenant(); + return GetEffective(tenant, flowType, clientProfile); + } + + public EffectiveUAuthServerOptions GetEffective(TenantKey tenant, AuthFlowType flowType, UAuthClientProfile clientProfile) + { + var original = _baseOptions.Value; + var effectiveMode = _modeResolver.Resolve(clientProfile, flowType); + var options = original.Clone(); + + ConfigureDefaults.ApplyModeDefaults(effectiveMode, options); + + if (original.ModeConfigurations.TryGetValue(effectiveMode, out var configure)) + { + configure(options); + } + + return new EffectiveUAuthServerOptions + { + Mode = effectiveMode, + Options = options + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs new file mode 100644 index 00000000..f17a63a6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/EffectiveUAuthServerOptions.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class EffectiveUAuthServerOptions +{ + public UAuthMode Mode { get; init; } + + /// + /// Cloned, per-request server options + /// + public UAuthServerOptions Options { get; init; } = default!; + + public UAuthResponseOptions AuthResponse => Options.AuthResponse; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs new file mode 100644 index 00000000..1d54a7de --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IClientProfileReader.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IClientProfileReader +{ + Task ReadAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs new file mode 100644 index 00000000..b53d9ef8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/IPrimaryTokenResolver.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IPrimaryTokenResolver +{ + PrimaryTokenKind Resolve(UAuthMode effectiveMode); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs new file mode 100644 index 00000000..8e5baa88 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/PrimaryTokenResolver.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class PrimaryTokenResolver : IPrimaryTokenResolver +{ + public PrimaryTokenKind Resolve(UAuthMode effectiveMode) + { + return effectiveMode switch + { + UAuthMode.PureOpaque => PrimaryTokenKind.Session, + UAuthMode.Hybrid => PrimaryTokenKind.Session, + UAuthMode.SemiHybrid => PrimaryTokenKind.AccessToken, + UAuthMode.PureJwt => PrimaryTokenKind.AccessToken, + _ => throw new InvalidOperationException( + $"Unsupported auth mode: {effectiveMode}") + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs new file mode 100644 index 00000000..acf78ac1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseOptionsModeTemplateResolver.cs @@ -0,0 +1,166 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthResponseOptionsModeTemplateResolver +{ + public UAuthResponseOptions Resolve(UAuthMode mode, AuthFlowType flowType) + { + return mode switch + { + UAuthMode.PureOpaque => PureOpaque(flowType), + UAuthMode.Hybrid => Hybrid(flowType), + UAuthMode.SemiHybrid => SemiHybrid(flowType), + UAuthMode.PureJwt => PureJwt(flowType), + _ => throw new InvalidOperationException($"Unsupported mode: {mode}") + }; + } + + private static UAuthResponseOptions PureOpaque(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = GrantKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie, + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = GrantKind.AccessToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = GrantKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } + }; + + private static UAuthResponseOptions Hybrid(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = GrantKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = GrantKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = GrantKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Cookie + }, + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } + }; + + private static UAuthResponseOptions SemiHybrid(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = GrantKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = GrantKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = GrantKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Header + }, + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } + }; + + private static UAuthResponseOptions PureJwt(AuthFlowType flow) + => new() + { + SessionIdDelivery = new() + { + Name = "uas", + Kind = GrantKind.Session, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.None + }, + AccessTokenDelivery = new() + { + Name = "uat", + Kind = GrantKind.AccessToken, + TokenFormat = TokenFormat.Jwt, + Mode = TokenResponseMode.Header + }, + RefreshTokenDelivery = new() + { + Name = "uar", + Kind = GrantKind.RefreshToken, + TokenFormat = TokenFormat.Opaque, + Mode = TokenResponseMode.Header + }, + + Login = new LoginRedirectOptions + { + RedirectEnabled = true + }, + + Logout = new LogoutRedirectOptions + { + RedirectEnabled = true + } + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs new file mode 100644 index 00000000..7d5bedd6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/AuthResponseResolver.cs @@ -0,0 +1,94 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class AuthResponseResolver : IAuthResponseResolver +{ + private readonly AuthResponseOptionsModeTemplateResolver _template; + private readonly ClientProfileAuthResponseAdapter _adapter; + + public AuthResponseResolver(AuthResponseOptionsModeTemplateResolver template, ClientProfileAuthResponseAdapter adapter) + { + _template = template; + _adapter = adapter; + } + + public EffectiveAuthResponse Resolve(UAuthMode effectiveMode, AuthFlowType flowType, UAuthClientProfile clientProfile, EffectiveUAuthServerOptions effectiveOptions) + { + var template = _template.Resolve(effectiveMode, flowType); + var adapted = _adapter.Adapt(template, clientProfile, effectiveMode, effectiveOptions); + + var bound = BindCookies(adapted, effectiveOptions.Options); + // TODO: This is currently implicit + Validate(bound); + + var redirect = ResolveRedirect(flowType, bound); + + return new EffectiveAuthResponse( + bound.SessionIdDelivery, + bound.AccessTokenDelivery, + bound.RefreshTokenDelivery, + redirect + ); + } + + private static UAuthResponseOptions BindCookies(UAuthResponseOptions response, UAuthServerOptions server) + { + return new UAuthResponseOptions + { + SessionIdDelivery = Bind(response.SessionIdDelivery, server), + AccessTokenDelivery = Bind(response.AccessTokenDelivery, server), + RefreshTokenDelivery = Bind(response.RefreshTokenDelivery, server), + Login = response.Login, + Logout = response.Logout + }; + } + + private static CredentialResponseOptions Bind(CredentialResponseOptions delivery, UAuthServerOptions server) + { + if (delivery.Mode != TokenResponseMode.Cookie) + return delivery; + + var cookie = delivery.Kind switch + { + GrantKind.Session => server.Cookie.Session, + GrantKind.AccessToken => server.Cookie.AccessToken, + GrantKind.RefreshToken => server.Cookie.RefreshToken, + _ => throw new InvalidOperationException($"Unsupported credential kind: {delivery.Kind}") + }; + + return delivery.WithCookie(cookie); + } + + private static void Validate(UAuthResponseOptions response) + { + ValidateDelivery(response.SessionIdDelivery); + ValidateDelivery(response.AccessTokenDelivery); + ValidateDelivery(response.RefreshTokenDelivery); + } + + private static void ValidateDelivery(CredentialResponseOptions delivery) + { + if (delivery.Mode == TokenResponseMode.Cookie && delivery.Cookie is null) + { + throw new InvalidOperationException($"Credential '{delivery.Kind}' is configured as Cookie but no cookie options were bound."); + } + } + + private static EffectiveRedirectResponse ResolveRedirect(AuthFlowType flowType, UAuthResponseOptions bound) + { + return flowType switch + { + AuthFlowType.Login or AuthFlowType.Reauthentication + => EffectiveRedirectResponse.FromLogin(bound.Login), + + AuthFlowType.Logout + => EffectiveRedirectResponse.FromLogout(bound.Logout), + + _ => EffectiveRedirectResponse.Disabled + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs new file mode 100644 index 00000000..cb64e072 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/ClientProfileAuthResponseAdapter.cs @@ -0,0 +1,80 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class ClientProfileAuthResponseAdapter +{ + public UAuthResponseOptions Adapt(UAuthResponseOptions template, UAuthClientProfile clientProfile, UAuthMode effectiveMode, EffectiveUAuthServerOptions effectiveOptions) + { + var configured = effectiveOptions.Options.AuthResponse; + + return new UAuthResponseOptions + { + SessionIdDelivery = AdaptCredential(template.SessionIdDelivery, GrantKind.Session, clientProfile), + AccessTokenDelivery = AdaptCredential(template.AccessTokenDelivery, GrantKind.AccessToken, clientProfile), + RefreshTokenDelivery = AdaptCredential(template.RefreshTokenDelivery, GrantKind.RefreshToken, clientProfile), + + Login = MergeLogin(template.Login, configured.Login), + Logout = MergeLogout(template.Logout, configured.Logout) + }; + } + + // NOTE: + // effectiveMode and effectiveOptions are intentionally passed + // to keep this adapter policy-extensible. + // They will be used for future mode/option based response enforcement. + private static CredentialResponseOptions AdaptCredential(CredentialResponseOptions original, GrantKind kind, UAuthClientProfile clientProfile) + { + if (clientProfile == UAuthClientProfile.Maui && original.Mode == TokenResponseMode.Cookie) + { + return ToHeader(original); + } + + if (original.TokenFormat == TokenFormat.Jwt && original.Mode == TokenResponseMode.Cookie) + { + return ToHeader(original); + } + + return original; + } + + private static CredentialResponseOptions ToHeader(CredentialResponseOptions original) + { + return new CredentialResponseOptions + { + TokenFormat = original.TokenFormat, + Mode = TokenResponseMode.Header, + HeaderFormat = HeaderTokenFormat.Bearer, + Name = original.Name + }; + } + + private static LoginRedirectOptions MergeLogin(LoginRedirectOptions template, LoginRedirectOptions configured) + { + return new LoginRedirectOptions + { + RedirectEnabled = configured.RedirectEnabled, + SuccessRedirect = configured.SuccessRedirect ?? template.SuccessRedirect, + FailureRedirect = configured.FailureRedirect ?? template.FailureRedirect, + FailureQueryKey = configured.FailureQueryKey ?? template.FailureQueryKey, + FailureCodes = configured.FailureCodes.Count > 0 + ? new Dictionary(configured.FailureCodes) + : new Dictionary(template.FailureCodes), + AllowReturnUrlOverride = configured.AllowReturnUrlOverride + }; + } + + private static LogoutRedirectOptions MergeLogout(LogoutRedirectOptions template, LogoutRedirectOptions configured) + { + return new LogoutRedirectOptions + { + RedirectEnabled = configured.RedirectEnabled, + RedirectUrl = configured.RedirectUrl ?? template.RedirectUrl, + AllowReturnUrlOverride = configured.AllowReturnUrlOverride + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs new file mode 100644 index 00000000..90865473 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthModeResolver.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +internal sealed class EffectiveAuthModeResolver : IEffectiveAuthModeResolver +{ + public UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType) + { + return clientProfile switch + { + UAuthClientProfile.BlazorServer => UAuthMode.PureOpaque, + UAuthClientProfile.WebServer => UAuthMode.Hybrid, + UAuthClientProfile.BlazorWasm => UAuthMode.Hybrid, + UAuthClientProfile.Maui => UAuthMode.Hybrid, + UAuthClientProfile.Api => UAuthMode.PureJwt, + _ => UAuthMode.Hybrid + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs new file mode 100644 index 00000000..7a82917d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveAuthResponse.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class EffectiveAuthResponse +{ + public CredentialResponseOptions SessionIdDelivery { get; } + public CredentialResponseOptions AccessTokenDelivery { get; } + public CredentialResponseOptions RefreshTokenDelivery { get; } + public EffectiveRedirectResponse Redirect { get; } + + public EffectiveAuthResponse( + CredentialResponseOptions sessionIdDelivery, + CredentialResponseOptions accessTokenDelivery, + CredentialResponseOptions refreshTokenDelivery, + EffectiveRedirectResponse redirect) + { + SessionIdDelivery = sessionIdDelivery; + AccessTokenDelivery = accessTokenDelivery; + RefreshTokenDelivery = refreshTokenDelivery; + Redirect = redirect; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs new file mode 100644 index 00000000..f042e790 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveLogoutRedirectResponse.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed record EffectiveLogoutRedirectResponse +( + bool RedirectEnabled, + string RedirectPath, + bool AllowReturnUrlOverride +); diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs new file mode 100644 index 00000000..9b91c745 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/EffectiveRedirectResponse.cs @@ -0,0 +1,57 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public sealed class EffectiveRedirectResponse +{ + public bool Enabled { get; } + public string? SuccessPath { get; } + public string? FailurePath { get; } + public IReadOnlyDictionary? FailureCodes { get; } + public bool AllowReturnUrlOverride { get; } + public bool IncludeLockoutTiming { get; } + public bool IncludeRemainingAttempts { get; } + + public EffectiveRedirectResponse( + bool enabled, + string? successPath, + string? failurePath, + IReadOnlyDictionary? failureCodes, + bool allowReturnUrlOverride, + bool includeLockoutTiming, + bool includeRemainingAttempts) + { + Enabled = enabled; + SuccessPath = successPath; + FailurePath = failurePath; + FailureCodes = failureCodes; + AllowReturnUrlOverride = allowReturnUrlOverride; + IncludeLockoutTiming = includeLockoutTiming; + IncludeRemainingAttempts = includeRemainingAttempts; + } + + public static readonly EffectiveRedirectResponse Disabled = new(false, null, null, null, false, false, false); + + public static EffectiveRedirectResponse FromLogin(LoginRedirectOptions login) + => new( + login.RedirectEnabled, + login.SuccessRedirect, + login.FailureRedirect, + login.FailureCodes, + login.AllowReturnUrlOverride, + login.IncludeLockoutTiming, + login.IncludeRemainingAttempts + ); + + public static EffectiveRedirectResponse FromLogout(LogoutRedirectOptions logout) + => new( + logout.RedirectEnabled, + logout.RedirectUrl, + null, + null, + logout.AllowReturnUrlOverride, + false, + false + ); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs new file mode 100644 index 00000000..080ca5a7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IAuthResponseResolver.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IAuthResponseResolver +{ + EffectiveAuthResponse Resolve(UAuthMode effectiveMode, AuthFlowType flowType, UAuthClientProfile clientProfile, EffectiveUAuthServerOptions effectiveOptions); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs new file mode 100644 index 00000000..71969091 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Auth/Response/IEffectiveAuthModeResolver.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Auth; + +public interface IEffectiveAuthModeResolver +{ + UAuthMode Resolve(UAuthClientProfile clientProfile, AuthFlowType flowType); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs new file mode 100644 index 00000000..e2b4ed54 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationExtension.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Authentication; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +public static class UAuthAuthenticationExtensions +{ + public static AuthenticationBuilder AddUAuthCookies(this AuthenticationBuilder builder, Action? configure = null) + { + return builder.AddScheme(UAuthConstants.SchemeDefaults.GlobalScheme, + options => + { + configure?.Invoke(options); + }); + } + + public static AuthenticationBuilder AddUAuthResourceApi(this AuthenticationBuilder builder) + { + return builder.AddScheme( + UAuthConstants.SchemeDefaults.GlobalScheme, + options => { }); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs new file mode 100644 index 00000000..96fff082 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationHandler.cs @@ -0,0 +1,133 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Text.Encodings.Web; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +internal sealed class UAuthAuthenticationHandler : AuthenticationHandler +{ + private readonly ITransportCredentialResolver _transportCredentialResolver; + private readonly ISessionValidator _sessionValidator; + private readonly IDeviceContextFactory _deviceContextFactory; + private readonly IAuthStateSnapshotFactory _snapshotFactory; + private readonly UAuthServerOptions _serverOptions; + private readonly IClock _clock; + + public UAuthAuthenticationHandler( + ITransportCredentialResolver transportCredentialResolver, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + ISessionValidator sessionValidator, + IDeviceContextFactory deviceContextFactory, + IAuthStateSnapshotFactory snapshotFactory, + IOptions serverOptions, + IClock uauthClock) + : base(options, logger, encoder) + { + _transportCredentialResolver = transportCredentialResolver; + _sessionValidator = sessionValidator; + _deviceContextFactory = deviceContextFactory; + _snapshotFactory = snapshotFactory; + _serverOptions = serverOptions.Value; + _clock = uauthClock; + } + + protected override async Task HandleAuthenticateAsync() + { + var credential = await _transportCredentialResolver.ResolveAsync(Context); + + if (credential is null) + return AuthenticateResult.NoResult(); + + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + return AuthenticateResult.Fail("Invalid credential"); + + var tenant = Context.GetTenant(); + + var result = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = tenant, + SessionId = sessionId, + Device = _deviceContextFactory.Create(credential.Device), + Now = _clock.UtcNow + }); + + if (!result.IsValid || result.UserKey is null) + return AuthenticateResult.NoResult(); + + var snapshot = await _snapshotFactory.CreateAsync(result); + + if (snapshot is null || snapshot.Identity is null) + return AuthenticateResult.NoResult(); + + var principal = snapshot.ToClaimsPrincipal(UAuthConstants.SchemeDefaults.GlobalScheme); + return AuthenticateResult.Success(new AuthenticationTicket(principal, UAuthConstants.SchemeDefaults.GlobalScheme)); + } + + protected override Task HandleChallengeAsync(AuthenticationProperties properties) + { + if (!_serverOptions.Navigation.EnableAutomaticNavigationRedirect) + { + Context.Response.StatusCode = 401; + return Task.CompletedTask; + } + + bool isNavigation = Context.Request.Headers["sec-fetch-mode"] == "navigate" && + Context.Request.Headers["Accept"].Any(a => a?.Contains("text/html") == true) && + !Context.Request.Headers.ContainsKey("X-Requested-With"); + + if (isNavigation) + { + var resolver = _serverOptions.Navigation.LoginResolver ?? (_ => UAuthConstants.Routes.LoginRedirect); + + var loginPath = resolver(Context); + var returnUrl = Context.Request.Path + Context.Request.QueryString; + var redirectUrl = $"{loginPath}?{UAuthConstants.Query.ReturnUrl}={Uri.EscapeDataString(returnUrl)}"; + + Context.Response.Redirect(redirectUrl); + return Task.CompletedTask; + } + + // fetch request โ†’ pure 401 + Context.Response.StatusCode = StatusCodes.Status401Unauthorized; + Context.Response.Headers[UAuthConstants.Headers.AuthState] = "unauthenticated"; + return Task.CompletedTask; + } + + protected override Task HandleForbiddenAsync(AuthenticationProperties properties) + { + if (!_serverOptions.Navigation.EnableAutomaticNavigationRedirect) + { + Context.Response.StatusCode = 403; + return Task.CompletedTask; + } + + bool isNavigation = Context.Request.Headers["sec-fetch-mode"] == "navigate" && + Context.Request.Headers["Accept"].Any(a => a?.Contains("text/html") == true) && + !Context.Request.Headers.ContainsKey("X-Requested-With"); + + if (isNavigation) + { + var resolver = _serverOptions.Navigation.AccessDeniedResolver ?? (_ => UAuthConstants.Routes.LoginRedirect); + var path = resolver(Context); + Context.Response.Redirect(path); + return Task.CompletedTask; + } + + Context.Response.StatusCode = StatusCodes.Status403Forbidden; + Context.Response.Headers[UAuthConstants.Headers.AuthState] = "forbidden"; + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs new file mode 100644 index 00000000..9f031e48 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthAuthenticationSchemeOptions.cs @@ -0,0 +1,11 @@ +๏ปฟusing Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +public sealed class UAuthAuthenticationSchemeOptions : AuthenticationSchemeOptions +{ + // TODO: + // - Claim mapping + // - Diagnostics flag +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs new file mode 100644 index 00000000..a479aa56 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AspNetCore/UAuthResourceAuthenticationHandler.cs @@ -0,0 +1,78 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Authentication; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Security.Claims; +using System.Text.Encodings.Web; + +namespace CodeBeam.UltimateAuth.Server.Authentication; + +internal sealed class UAuthResourceAuthenticationHandler : AuthenticationHandler +{ + private readonly ITransportCredentialResolver _credentialResolver; + private readonly ISessionValidator _sessionValidator; + private readonly IDeviceContextFactory _deviceFactory; + private readonly IClock _clock; + + public UAuthResourceAuthenticationHandler( + ITransportCredentialResolver credentialResolver, + ISessionValidator sessionValidator, + IDeviceContextFactory deviceFactory, + IClock clock, + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder) + : base(options, logger, encoder) + { + _credentialResolver = credentialResolver; + _sessionValidator = sessionValidator; + _deviceFactory = deviceFactory; + _clock = clock; + } + + protected override async Task HandleAuthenticateAsync() + { + var credential = await _credentialResolver.ResolveAsync(Context); + + if (credential is null) + return AuthenticateResult.NoResult(); + + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + return AuthenticateResult.Fail("Invalid session"); + + var tenant = Context.GetTenant(); + + var result = await _sessionValidator.ValidateSessionAsync(new SessionValidationContext + { + Tenant = tenant, + SessionId = sessionId, + Device = _deviceFactory.Create(credential.Device), + Now = _clock.UtcNow + }); + + if (!result.IsValid || result.UserKey is null) + return AuthenticateResult.NoResult(); + + var claims = new List + { + new Claim(ClaimTypes.NameIdentifier, result.UserKey.Value) + }; + + foreach (var (type, values) in result.Claims.Claims) + { + foreach (var value in values) + { + claims.Add(new Claim(type, value)); + } + } + + var identity = new ClaimsIdentity(claims, Scheme.Name); + var principal = new ClaimsPrincipal(identity); + + return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs new file mode 100644 index 00000000..59de49b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authentication/AuthenticationSecurityManager.cs @@ -0,0 +1,64 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Security; + +internal sealed class AuthenticationSecurityManager : IAuthenticationSecurityManager +{ + private readonly IAuthenticationSecurityStateStoreFactory _storeFactory; + private readonly UAuthServerOptions _options; + + public AuthenticationSecurityManager(IAuthenticationSecurityStateStoreFactory storeFactory, IOptions options) + { + _storeFactory = storeFactory; + _options = options.Value; + } + + public async Task GetOrCreateAccountAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var store = _storeFactory.Create(tenant); + var state = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, credentialType: null, ct); + + if (state is not null) + return state; + + var created = AuthenticationSecurityState.CreateAccount(tenant, userKey); + await store.AddAsync(created, ct); + return created; + } + + public async Task GetOrCreateFactorAsync(TenantKey tenant, UserKey userKey, CredentialType type, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var store = _storeFactory.Create(tenant); + var state = await store.GetAsync(userKey, AuthenticationSecurityScope.Factor, type, ct); + + if (state is not null) + return state; + + var created = AuthenticationSecurityState.CreateFactor(tenant, userKey, type); + await store.AddAsync(created, ct); + return created; + } + + public Task UpdateAsync(AuthenticationSecurityState updated, long expectedVersion, CancellationToken ct = default) + { + var store = _storeFactory.Create(updated.Tenant); + return store.UpdateAsync(updated, expectedVersion, ct); + } + + public Task DeleteAsync(TenantKey tenant, UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var store = _storeFactory.Create(tenant); + return store.DeleteAsync(userKey, scope, credentialType, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs new file mode 100644 index 00000000..1b18149b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/ResourceAccessContextBuilder.cs @@ -0,0 +1,36 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Authorization; + +internal static class ResourceAccessContextBuilder +{ + public static AccessContext Create(HttpContext http, string action) + { + var user = http.RequestServices.GetRequiredService(); + + return new AccessContext( + actorUserKey: user.IsAuthenticated ? user.UserKey : null, + actorTenant: http.GetTenant(), + isAuthenticated: user.IsAuthenticated, + isSystemActor: false, + actorChainId: null, + + resource: ResolveResource(action), + targetUserKey: null, + resourceTenant: http.GetTenant(), + + action: action, + attributes: EmptyAttributes.Instance + ); + } + + private static string ResolveResource(string action) + { + var parts = action.Split('.'); + return parts.Length > 0 ? parts[0] : "unknown"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs new file mode 100644 index 00000000..e22240e9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthActionRequirement.cs @@ -0,0 +1,13 @@ +๏ปฟusing Microsoft.AspNetCore.Authorization; + +namespace CodeBeam.UltimateAuth.Server.Authorization; + +public sealed class UAuthActionRequirement : IAuthorizationRequirement +{ + public string Action { get; } + + public UAuthActionRequirement(string action) + { + Action = action; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs new file mode 100644 index 00000000..3df4852a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthAuthorizationHandler.cs @@ -0,0 +1,43 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Authorization; + +public sealed class UAuthAuthorizationHandler : AuthorizationHandler +{ + private readonly IAccessOrchestrator _orchestrator; + private readonly IHttpContextAccessor _httpContextAccessor; + + public UAuthAuthorizationHandler( + IAccessOrchestrator orchestrator, + IHttpContextAccessor httpContextAccessor) + { + _orchestrator = orchestrator; + _httpContextAccessor = httpContextAccessor; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, UAuthActionRequirement requirement) + { + var http = _httpContextAccessor.HttpContext!; + var accessContext = ResourceAccessContextBuilder.Create(http, requirement.Action); + + try + { + await _orchestrator.ExecuteAsync( + accessContext, + new AccessCommand(_ => Task.CompletedTask)); + + context.Succeed(requirement); + } + catch (UAuthAuthorizationException) + { + // deny + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs new file mode 100644 index 00000000..147b87dc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthPolicyProvider.cs @@ -0,0 +1,29 @@ +๏ปฟusing Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Authorization; + +public class UAuthPolicyProvider : IAuthorizationPolicyProvider +{ + private readonly DefaultAuthorizationPolicyProvider _fallback; + + public UAuthPolicyProvider(IOptions options) + { + _fallback = new DefaultAuthorizationPolicyProvider(options); + } + + public Task GetPolicyAsync(string policyName) + { + var policy = new AuthorizationPolicyBuilder() + .AddRequirements(new UAuthActionRequirement(policyName)) + .Build(); + + return Task.FromResult(policy); + } + + public Task GetDefaultPolicyAsync() + => _fallback.GetDefaultPolicyAsync(); + + public Task GetFallbackPolicyAsync() + => _fallback.GetFallbackPolicyAsync(); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs new file mode 100644 index 00000000..302b4327 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Authorization/AspNetCore/UAuthResourceAccessOrchestrator.cs @@ -0,0 +1,77 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Authorization; + +public sealed class UAuthResourceAccessOrchestrator : IAccessOrchestrator +{ + private readonly IAccessAuthority _authority; + private readonly IAccessPolicyProvider _policyProvider; + private readonly IHttpContextAccessor _http; + + public UAuthResourceAccessOrchestrator( + IAccessAuthority authority, + IAccessPolicyProvider policyProvider, + IHttpContextAccessor http) + { + _authority = authority; + _policyProvider = policyProvider; + _http = http; + } + + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + context = EnrichFromClaims(context); + + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); + + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); + + await command.ExecuteAsync(ct); + } + + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + context = EnrichFromClaims(context); + + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); + + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); + + return await command.ExecuteAsync(ct); + } + + private AccessContext EnrichFromClaims(AccessContext context) + { + var http = _http.HttpContext!; + var user = http.User; + + var permissions = user.Claims + .Where(c => c.Type == "uauth:permission") + .Select(c => Permission.From(c.Value)); + + var compiled = new CompiledPermissionSet(permissions); + + return context.WithAttribute(UAuthConstants.Access.Permissions, compiled); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj index 8004a0dd..8694bcb9 100644 --- a/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj +++ b/src/CodeBeam.UltimateAuth.Server/CodeBeam.UltimateAuth.Server.csproj @@ -2,9 +2,40 @@ net8.0;net9.0;net10.0 - enable - enable - true + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Server + + + Main server package for UltimateAuth. + Includes authentication, authorization, users, credentials, sessions, tokens and policy modules with dependency injection setup. + This is the primary package to install for backend applications. + + + authentication;authorization;identity;security;server;oauth;pkce;jwt;aspnetcore;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + + + + + + + diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs new file mode 100644 index 00000000..91617a35 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Composition/Extensions/AddUltimateAuthServerExtensions.cs @@ -0,0 +1,20 @@ +๏ปฟ//using CodeBeam.UltimateAuth.Core.Extensions; +//using CodeBeam.UltimateAuth.Server.Extensions; +//using CodeBeam.UltimateAuth.Server.Options; +//using Microsoft.Extensions.Configuration; +//using Microsoft.Extensions.DependencyInjection; + +//namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; + +//public static class AddUltimateAuthServerExtensions +//{ +// public static UltimateAuthServerBuilder AddUltimateAuthServer(this IServiceCollection services, IConfiguration configuration) +// { +// services.AddUltimateAuth(configuration); // Core +// services.AddUAuthServerInfrastructure(); // issuer, flow, endpoints + +// services.Configure(configuration.GetSection("UltimateAuth:Server")); + +// return new UltimateAuthServerBuilder(services); +// } +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs new file mode 100644 index 00000000..1a119390 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilder.cs @@ -0,0 +1,13 @@ +๏ปฟusing Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Composition; + +public sealed class UltimateAuthServerBuilder +{ + internal UltimateAuthServerBuilder(IServiceCollection services) + { + Services = services; + } + + public IServiceCollection Services { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs new file mode 100644 index 00000000..8370bce1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Composition/UltimateAuthServerBuilderValidation.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Composition; + +public static class UltimateAuthServerBuilderValidationExtensions +{ + public static IServiceCollection Build(this UltimateAuthServerBuilder builder) + { + var services = builder.Services; + + if (!services.Any(sd => sd.ServiceType == typeof(IUAuthPasswordHasher))) + throw new InvalidOperationException("No IUAuthPasswordHasher registered. Call UseArgon2() or another hasher."); + + //if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(IUAuthUserStore<>)))) + // throw new InvalidOperationException("No credential store registered."); + + if (!services.Any(sd => sd.ServiceType.IsAssignableTo(typeof(ISessionStore)))) + throw new InvalidOperationException("No session store registered."); + + return services; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs new file mode 100644 index 00000000..81c22c36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubBeginRequest.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Contracts; + +public sealed record HubBeginRequest +{ + public string AuthorizationCode { get; init; } = default!; + public string CodeVerifier { get; init; } = default!; + + public UAuthClientProfile ClientProfile { get; init; } + public TenantKey Tenant { get; init; } + + public string? ReturnUrl { get; init; } + + public string? PreviousHubSessionId { get; init; } + + public required DeviceContext Device { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs new file mode 100644 index 00000000..288b531e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/Hub/HubSessionResult.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Contracts; + +public sealed record HubSessionResult +{ + public string HubSessionId { get; init; } = default!; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/JwtSigningKey.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/JwtSigningKey.cs new file mode 100644 index 00000000..36f6dfcb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/JwtSigningKey.cs @@ -0,0 +1,10 @@ +๏ปฟusing Microsoft.IdentityModel.Tokens; + +namespace CodeBeam.UltimateAuth.Server.Contracts; + +public sealed class JwtSigningKey +{ + public required string KeyId { get; init; } + public required SecurityKey Key { get; init; } + public required string Algorithm { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs new file mode 100644 index 00000000..98c509f8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/ResolvedCredential.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.Contracts; + +public sealed record ResolvedCredential +{ + public PrimaryGrantKind Kind { get; init; } + + /// + /// Raw credential value (session id / jwt / opaque) + /// + public string Value { get; init; } = default!; + + public TenantKey Tenant { get; init; } + + public DeviceInfo Device { get; init; } = default!; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs new file mode 100644 index 00000000..e0d6fc42 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Contracts/SessionRefreshResult.cs @@ -0,0 +1,16 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Contracts; + +public sealed class SessionRefreshResult +{ + public bool Succeeded { get; } + public string? NewSessionId { get; } + + private SessionRefreshResult(bool succeeded, string? newSessionId) + { + Succeeded = succeeded; + NewSessionId = newSessionId; + } + + public static SessionRefreshResult Success(string? newSessionId = null) => new(true, newSessionId); + public static SessionRefreshResult Failed() => new(false, null); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs new file mode 100644 index 00000000..b993991a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthDiagnostic.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Diagnostics; + +public sealed record UAuthDiagnostic(string code, string message, UAuthDiagnosticSeverity severity); + +public enum UAuthDiagnosticSeverity +{ + Info, + Warning, + Error +} diff --git a/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs new file mode 100644 index 00000000..c43e7083 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Diagnostics/UAuthStartupDiagnostics.cs @@ -0,0 +1,57 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Diagnostics; + +internal static class UAuthStartupDiagnostics +{ + public static IEnumerable Analyze(UAuthServerOptions options) + { + foreach (var d in AnalyzeCookies(options)) + yield return d; + } + + private static IEnumerable AnalyzeCookies(UAuthServerOptions options) + { + if (options.HubDeploymentMode != UAuthHubDeploymentMode.External) + yield break; + + var session = options.Cookie.Session; + + if (session.SameSite == SameSiteMode.None && + session.SecurePolicy != CookieSecurePolicy.Always) + { + yield return new UAuthDiagnostic( + code: "UAUTH001", + message: + "Session cookie uses SameSite=None without Secure in External deployment. " + + "This is insecure and may expose authentication to network attackers.", + severity: UAuthDiagnosticSeverity.Error); + } + + var refresh = options.Cookie.RefreshToken; + + if (refresh.SameSite == SameSiteMode.None && + refresh.SecurePolicy != CookieSecurePolicy.Always) + { + yield return new UAuthDiagnostic( + code: "UAUTH002", + message: + "Refresh token cookie uses SameSite=None without Secure in External deployment. " + + "This is a critical security risk and MUST NOT be used outside development.", + severity: UAuthDiagnosticSeverity.Error); + } + + // TODO: Think again with MAUI. + if (!refresh.HttpOnly) + { + yield return new UAuthDiagnostic( + code: "UAUTH003", + message: + "Refresh token cookie is not HttpOnly. This allows JavaScript access and " + + "significantly increases the impact of XSS vulnerabilities.", + severity: UAuthDiagnosticSeverity.Warning); + } + } +} + diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs new file mode 100644 index 00000000..43c65375 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IAuthorizationEndpointHandler.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IAuthorizationEndpointHandler +{ + Task CheckAsync(HttpContext ctx); + Task GetMyRolesAsync(HttpContext ctx); + Task GetUserRolesAsync(UserKey userKey, HttpContext ctx); + Task AssignRoleAsync(UserKey userKey, HttpContext ctx); + Task RemoveRoleAsync(UserKey userKey, HttpContext ctx); + + Task CreateRoleAsync(HttpContext ctx); + Task RenameRoleAsync(RoleId roleId, HttpContext ctx); + Task DeleteRoleAsync(RoleId roleId, HttpContext ctx); + Task SetRolePermissionsAsync(RoleId roleId, HttpContext ctx); + Task QueryRolesAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs new file mode 100644 index 00000000..1e766bf0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ICredentialEndpointHandler.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ICredentialEndpointHandler +{ + Task GetAllAsync(HttpContext ctx); + Task AddAsync(HttpContext ctx); + Task ChangeSecretAsync(HttpContext ctx); + Task RevokeAsync(HttpContext ctx); + Task BeginResetAsync(HttpContext ctx); + Task CompleteResetAsync(HttpContext ctx); + + Task GetAllAdminAsync(UserKey userKey, HttpContext ctx); + Task AddAdminAsync(UserKey userKey, HttpContext ctx); + Task ChangeSecretAdminAsync(UserKey userKey, HttpContext ctx); + Task RevokeAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteAdminAsync(UserKey userKey, HttpContext ctx); + Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx); + Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs new file mode 100644 index 00000000..a275b413 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILoginEndpointHandler.cs @@ -0,0 +1,9 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ILoginEndpointHandler +{ + Task LoginAsync(HttpContext ctx); + Task TryLoginAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs new file mode 100644 index 00000000..583229f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ILogoutEndpointHandler.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ILogoutEndpointHandler +{ + Task LogoutAsync(HttpContext ctx); + Task LogoutDeviceSelfAsync(HttpContext ctx); + Task LogoutOthersSelfAsync(HttpContext ctx); + Task LogoutAllSelfAsync(HttpContext ctx); + + Task LogoutDeviceAdminAsync(HttpContext ctx, UserKey userKey); + Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userKey); + Task LogoutAllAdminAsync(HttpContext ctx, UserKey userKey); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs new file mode 100644 index 00000000..bc5cd909 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IPkceEndpointHandler.cs @@ -0,0 +1,22 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IPkceEndpointHandler +{ + /// + /// Starts the PKCE authorization flow. + /// Creates and stores a PKCE authorization artifact + /// and returns an authorization code or redirect instruction. + /// + Task AuthorizeAsync(HttpContext ctx); + + /// + /// Completes the PKCE flow. + /// Atomically validates and consumes the authorization code, + /// then issues a session or token. + /// + Task CompleteAsync(HttpContext ctx); + + Task TryCompleteAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs new file mode 100644 index 00000000..8dfe0c20 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IReauthEndpointHandler.cs @@ -0,0 +1,8 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IReauthEndpointHandler +{ + Task ReauthAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs new file mode 100644 index 00000000..70cfc80f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IRefreshEndpointHandler.cs @@ -0,0 +1,8 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IRefreshEndpointHandler +{ + Task RefreshAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs new file mode 100644 index 00000000..31681539 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ISessionEndpointHandler.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ISessionEndpointHandler +{ + Task GetMyChainsAsync(HttpContext ctx); + Task GetMyChainDetailAsync(SessionChainId chainId, HttpContext ctx); + Task RevokeMyChainAsync(SessionChainId chainId, HttpContext ctx); + Task RevokeOtherChainsAsync(HttpContext ctx); + Task RevokeAllMyChainsAsync(HttpContext ctx); + + Task GetUserChainsAsync(UserKey userKey, HttpContext ctx); + Task GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx); + Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId, HttpContext ctx); + Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx); + Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx); + Task RevokeRootAsync(UserKey userKey, HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs new file mode 100644 index 00000000..0056c86f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/ITokenEndpointHandler.cs @@ -0,0 +1,11 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface ITokenEndpointHandler +{ + Task GetTokenAsync(HttpContext ctx); + Task RefreshTokenAsync(HttpContext ctx); + Task IntrospectAsync(HttpContext ctx); + Task RevokeAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUAuthEndpointRegistrar.cs new file mode 100644 index 00000000..7d9f3264 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUAuthEndpointRegistrar.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Routing; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IAuthEndpointRegistrar +{ + void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs new file mode 100644 index 00000000..8e43a97b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserEndpointHandler.cs @@ -0,0 +1,39 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IUserEndpointHandler +{ + Task QueryUsersAsync(HttpContext ctx); + Task CreateAsync(HttpContext ctx); + Task CreateAdminAsync(HttpContext ctx); + Task ChangeStatusSelfAsync(HttpContext ctx); + Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteMeAsync(HttpContext ctx); + Task DeleteAsync(UserKey userKey, HttpContext ctx); + + Task GetMeAsync(HttpContext ctx); + Task UpdateMeAsync(HttpContext ctx); + + Task GetUserAsync(UserKey userKey, HttpContext ctx); + Task UpdateUserAsync(UserKey userKey, HttpContext ctx); + + Task GetMyIdentifiersAsync(HttpContext ctx); + Task IdentifierExistsSelfAsync(HttpContext ctx); + Task AddUserIdentifierSelfAsync(HttpContext ctx); + Task UpdateUserIdentifierSelfAsync(HttpContext ctx); + Task SetPrimaryUserIdentifierSelfAsync(HttpContext ctx); + Task UnsetPrimaryUserIdentifierSelfAsync(HttpContext ctx); + Task VerifyUserIdentifierSelfAsync(HttpContext ctx); + Task DeleteUserIdentifierSelfAsync(HttpContext ctx); + + Task GetUserIdentifiersAsync(UserKey userKey, HttpContext ctx); + Task IdentifierExistsAdminAsync(UserKey userKey, HttpContext ctx); + Task AddUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task UpdateUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task SetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task UnsetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task VerifyUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); + Task DeleteUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs new file mode 100644 index 00000000..04a1a23c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IUserInfoEndpointHandler.cs @@ -0,0 +1,10 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IUserInfoEndpointHandler +{ + Task GetUserInfoAsync(HttpContext ctx); + Task GetPermissionsAsync(HttpContext ctx); + Task CheckPermissionAsync(HttpContext ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs new file mode 100644 index 00000000..d97aee7e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Abstractions/IValidateEndpointHandler.cs @@ -0,0 +1,8 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public interface IValidateEndpointHandler +{ + Task ValidateAsync(HttpContext context, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs new file mode 100644 index 00000000..9639a42f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LoginEndpointHandlerBridge.cs @@ -0,0 +1,16 @@ +๏ปฟ//using CodeBeam.UltimateAuth.Core.Domain; +//using Microsoft.AspNetCore.Http; + +//namespace CodeBeam.UltimateAuth.Server.Endpoints; + +//internal sealed class LoginEndpointHandlerBridge : ILoginEndpointHandler +//{ +// private readonly LoginEndpointHandler _inner; + +// public LoginEndpointHandlerBridge(LoginEndpointHandler inner) +// { +// _inner = inner; +// } + +// public Task LoginAsync(HttpContext ctx) => _inner.LoginAsync(ctx); +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs new file mode 100644 index 00000000..710cf06e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/LogoutEndpointHandlerBridge.cs @@ -0,0 +1,17 @@ +๏ปฟ//using CodeBeam.UltimateAuth.Core.Domain; +//using Microsoft.AspNetCore.Http; + +//namespace CodeBeam.UltimateAuth.Server.Endpoints; + +//internal sealed class LogoutEndpointHandlerBridge : ILogoutEndpointHandler +//{ +// private readonly LogoutEndpointHandler _inner; + +// public LogoutEndpointHandlerBridge(LogoutEndpointHandler inner) +// { +// _inner = inner; +// } + +// public Task LogoutAsync(HttpContext ctx) +// => _inner.LogoutAsync(ctx); +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs new file mode 100644 index 00000000..1b5aef95 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/PkceEndpointHandlerBridge.cs @@ -0,0 +1,18 @@ +๏ปฟ//using CodeBeam.UltimateAuth.Core.Domain; +//using Microsoft.AspNetCore.Http; + +//namespace CodeBeam.UltimateAuth.Server.Endpoints; + +//internal sealed class PkceEndpointHandlerBridge : IPkceEndpointHandler +//{ +// private readonly PkceEndpointHandler _inner; + +// public PkceEndpointHandlerBridge(PkceEndpointHandler inner) +// { +// _inner = inner; +// } + +// public Task AuthorizeAsync(HttpContext ctx) => _inner.AuthorizeAsync(ctx); + +// public Task CompleteAsync(HttpContext ctx) => _inner.CompleteAsync(ctx); +//} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs new file mode 100644 index 00000000..9a23cc1b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/RefreshEndpointHandlerBridge.cs @@ -0,0 +1,15 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class RefreshEndpointHandlerBridge : IRefreshEndpointHandler +{ + private readonly RefreshEndpointHandler _inner; + + public RefreshEndpointHandlerBridge(RefreshEndpointHandler inner) + { + _inner = inner; + } + + public Task RefreshAsync(HttpContext ctx) => _inner.RefreshAsync(ctx); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs new file mode 100644 index 00000000..412bcef8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/Bridges/ValidateEndpointHandlerBridge.cs @@ -0,0 +1,15 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class ValidateEndpointHandlerBridge : IValidateEndpointHandler +{ + private readonly ValidateEndpointHandler _inner; + + public ValidateEndpointHandlerBridge(ValidateEndpointHandler inner) + { + _inner = inner; + } + + public Task ValidateAsync(HttpContext context, CancellationToken ct = default) => _inner.ValidateAsync(context, ct); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs new file mode 100644 index 00000000..5d6b0885 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LoginEndpointHandler.cs @@ -0,0 +1,246 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Users; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class LoginEndpointHandler : ILoginEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IUAuthInternalFlowService _internalFlowService; + private readonly ICredentialResponseWriter _credentialResponseWriter; + private readonly IAuthRedirectResolver _redirectResolver; + private readonly IAuthStore _authStore; + private readonly ILoginIdentifierResolver _loginIdentifierResolver; + private readonly UAuthServerOptions _options; + private readonly IClock _clock; + + public LoginEndpointHandler( + IAuthFlowContextAccessor authFlow, + IUAuthInternalFlowService internalFlowService, + ICredentialResponseWriter credentialResponseWriter, + IAuthRedirectResolver redirectResolver, + IAuthStore authStore, + ILoginIdentifierResolver loginIdentifierResolver, + IOptions options, + IClock clock) + { + _authFlow = authFlow; + _internalFlowService = internalFlowService; + _credentialResponseWriter = credentialResponseWriter; + _redirectResolver = redirectResolver; + _authStore = authStore; + _loginIdentifierResolver = loginIdentifierResolver; + _options = options.Value; + _clock = clock; + } + + public async Task LoginAsync(HttpContext ctx) + { + var authFlow = _authFlow.Current; + var request = await ReadLoginRequestAsync(ctx); + + if (request is null || string.IsNullOrWhiteSpace(request.Identifier) || string.IsNullOrWhiteSpace(request.Secret)) + { + var failure = _redirectResolver.ResolveFailure(authFlow, ctx, AuthFailureReason.InvalidCredentials); + return failure.Enabled ? Results.Redirect(failure.TargetUrl!) : Results.Unauthorized(); + } + + var suppressFailureAttempt = false; + + if (!string.IsNullOrWhiteSpace(request.PreviewReceipt) && authFlow.Device.DeviceId is DeviceId deviceId) + { + var key = new AuthArtifactKey(request.PreviewReceipt); + var artifact = await _authStore.GetAsync(key, ctx.RequestAborted) as LoginPreviewArtifact; + + if (artifact is not null) + { + var fingerprint = LoginPreviewFingerprint.Create( + authFlow.Tenant, + request.Identifier, + CredentialType.Password, + request.Secret, + deviceId); + + if (artifact.Matches( + authFlow.Tenant, + artifact.UserKey, + CredentialType.Password, + deviceId.Value, + request.Identifier, + authFlow.ClientProfile, + fingerprint)) + { + suppressFailureAttempt = true; + await _authStore.ConsumeAsync(key, ctx.RequestAborted); + } + } + } + + var flowRequest = new LoginRequest + { + Identifier = request.Identifier, + Secret = request.Secret, + Factor = CredentialType.Password, + PreviewReceipt = request.PreviewReceipt, + RequestTokens = authFlow.AllowsTokenIssuance, + Metadata = request.Metadata, + }; + + var result = await _internalFlowService.LoginAsync(authFlow, flowRequest, + new LoginExecutionOptions + { + Mode = LoginExecutionMode.Commit, + SuppressFailureAttempt = suppressFailureAttempt, + SuppressSuccessReset = false + }, ctx.RequestAborted); + + if (!result.IsSuccess) + { + var decisionFailure = _redirectResolver.ResolveFailure(authFlow, ctx, result.FailureReason ?? AuthFailureReason.Unknown, result); + + return decisionFailure.Enabled + ? Results.Redirect(decisionFailure.TargetUrl!) + : Results.Unauthorized(); + } + + if (result.SessionId is AuthSessionId sessionId) + { + _credentialResponseWriter.Write(ctx, GrantKind.Session, sessionId); + } + + if (result.AccessToken is not null) + { + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); + } + + if (result.RefreshToken is not null) + { + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); + } + + var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); + + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(); + } + + public async Task TryLoginAsync(HttpContext ctx) + { + var authFlow = _authFlow.Current; + + if (!ctx.Request.HasFormContentType && !ctx.Request.HasJsonContentType()) + return Results.BadRequest("Invalid content type."); + + var request = await ReadLoginRequestAsync(ctx); + if (request is null) + return Results.BadRequest("Invalid payload."); + + if (string.IsNullOrWhiteSpace(request.Identifier) || string.IsNullOrWhiteSpace(request.Secret)) + { + return Results.Ok(new TryLoginResult + { + Success = false, + Reason = AuthFailureReason.InvalidCredentials + }); + } + + request = new LoginRequest + { + Identifier = request.Identifier, + Secret = request.Secret, + Factor = CredentialType.Password, + PreviewReceipt = request.PreviewReceipt, + RequestTokens = authFlow.AllowsTokenIssuance, + Metadata = request.Metadata + }; + + var result = await _internalFlowService.LoginAsync( + authFlow, + request, + new LoginExecutionOptions + { + Mode = LoginExecutionMode.Preview, + SuppressFailureAttempt = false, + SuppressSuccessReset = true + }, + ctx.RequestAborted); + + string? previewReceipt = null; + + if (result.IsSuccess && authFlow.Device.DeviceId is DeviceId deviceId) + { + var fingerprint = LoginPreviewFingerprint.Create( + authFlow.Tenant, + request.Identifier, + request.Factor, + request.Secret, + deviceId); + + var receipt = AuthArtifactKey.New(); + previewReceipt = receipt.Value; + + var resolution = await _loginIdentifierResolver.ResolveAsync(authFlow.Tenant, request.Identifier, ctx.RequestAborted); + + if (resolution?.UserKey is { } userKey) + { + var artifact = new LoginPreviewArtifact( + authFlow.Tenant, + userKey, + request.Factor, + deviceId.Value, + request.Identifier, + authFlow.ClientProfile, + fingerprint, + _clock.UtcNow.Add(_options.Login.TryLoginDuration)); + + await _authStore.StoreAsync(receipt, artifact, ctx.RequestAborted); + } + } + + return Results.Ok(new TryLoginResult + { + Success = result.IsSuccess, + Reason = result.FailureReason, + RemainingAttempts = result.RemainingAttempts, + LockoutUntilUtc = result.LockoutUntilUtc, + RequiresMfa = result.RequiresMfa, + PreviewReceipt = previewReceipt + }); + } + + private async Task ReadLoginRequestAsync(HttpContext ctx) + { + if (ctx.Request.HasJsonContentType()) + { + return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted); + } + + if (ctx.Request.HasFormContentType) + { + var form = await ctx.GetCachedFormAsync(); + + return new LoginRequest + { + Identifier = form?["Identifier"].FirstOrDefault() ?? string.Empty, + Secret = form?["Secret"].FirstOrDefault() ?? string.Empty, + PreviewReceipt = form?["PreviewReceipt"].FirstOrDefault(), + // TODO: Other properties? + }; + } + + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs new file mode 100644 index 00000000..dd7b1e36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/LogoutEndpointHandler.cs @@ -0,0 +1,186 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public sealed class LogoutEndpointHandler : ILogoutEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authContext; + private readonly IUAuthFlowService _flow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ISessionApplicationService _sessionApplicationService; + private readonly IClock _clock; + private readonly IUAuthCookieManager _cookieManager; + private readonly IAuthRedirectResolver _redirectResolver; + + public LogoutEndpointHandler(IAuthFlowContextAccessor authContext, IUAuthFlowService flow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessionApplicationService, IClock clock, IUAuthCookieManager cookieManager, IAuthRedirectResolver redirectResolver) + { + _authContext = authContext; + _flow = flow; + _accessContextFactory = accessContextFactory; + _sessionApplicationService = sessionApplicationService; + _clock = clock; + _cookieManager = cookieManager; + _redirectResolver = redirectResolver; + } + + public async Task LogoutAsync(HttpContext ctx) + { + var authFlow = _authContext.Current; + + if (authFlow.Session is SessionSecurityContext session) + { + var request = new LogoutRequest + { + SessionId = session.SessionId + }; + + await _flow.LogoutAsync(request, ctx.RequestAborted); + } + + DeleteIfCookie(ctx, authFlow.Response.SessionIdDelivery); + DeleteIfCookie(ctx, authFlow.Response.RefreshTokenDelivery); + DeleteIfCookie(ctx, authFlow.Response.AccessTokenDelivery); + + var decision = _redirectResolver.ResolveSuccess(authFlow, ctx); + + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(new LogoutResponse { Success = true }); + } + + public async Task LogoutDeviceSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutDeviceSelf, + resource: "flows", + resourceId: userKey.Value); + + var result = await _sessionApplicationService.LogoutDeviceAsync(access, request.ChainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task LogoutDeviceAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutDeviceAdmin, + resource: "flows", + resourceId: userKey.Value); + + var result = await _sessionApplicationService.LogoutDeviceAsync(access, request.ChainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task LogoutOthersSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutOthersSelf, + resource: "flows", + resourceId: userKey.Value); + + if (access.ActorChainId is not SessionChainId chainId) + return Results.Unauthorized(); + + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutOthersAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutOthersAdmin, + resource: "flows", + resourceId: userKey.Value); + + await _sessionApplicationService.LogoutOtherDevicesAsync(access, userKey, request.CurrentChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutAllSelfAsync(HttpContext ctx) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + if (flow.UserKey is not UserKey userKey) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutAllSelf, + resource: "flows", + resourceId: userKey); + + await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task LogoutAllAdminAsync(HttpContext ctx, UserKey userKey) + { + var flow = _authContext.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Flows.LogoutAllAdmin, + resource: "flows", + resourceId: userKey.Value); + + await _sessionApplicationService.LogoutAllDevicesAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } + + private void DeleteIfCookie(HttpContext ctx, CredentialResponseOptions delivery) + { + if (delivery.Mode != TokenResponseMode.Cookie) + return; + + if (delivery.Cookie == null) + return; + + _cookieManager.Delete(ctx, delivery.Cookie.Name); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs new file mode 100644 index 00000000..9743e39e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/PkceEndpointHandler.cs @@ -0,0 +1,329 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class PkceEndpointHandler : IPkceEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authContext; + private readonly IUAuthFlowService _flow; + private readonly IPkceService _pkceService; + private readonly IUAuthInternalFlowService _internalFlowService; + private readonly IAuthStore _authStore; + private readonly IPkceAuthorizationValidator _validator; + private readonly IClock _clock; + private readonly ICredentialResponseWriter _credentialResponseWriter; + private readonly IAuthRedirectResolver _redirectResolver; + + public PkceEndpointHandler( + IAuthFlowContextAccessor authContext, + IUAuthFlowService flow, + IPkceService pkceService, + IUAuthInternalFlowService internalFlowService, + IAuthStore authStore, + IPkceAuthorizationValidator validator, + IClock clock, + ICredentialResponseWriter credentialResponseWriter, + IAuthRedirectResolver redirectResolver) + { + _authContext = authContext; + _flow = flow; + _pkceService = pkceService; + _internalFlowService = internalFlowService; + _authStore = authStore; + _validator = validator; + _clock = clock; + _credentialResponseWriter = credentialResponseWriter; + _redirectResolver = redirectResolver; + } + + public async Task AuthorizeAsync(HttpContext ctx) + { + var auth = _authContext.Current; + + var request = await ReadPkceAuthorizeRequestAsync(ctx); + if (request is null) + return Results.BadRequest("Invalid content type."); + + var result = await _pkceService.AuthorizeAsync( + new PkceAuthorizeCommand + { + CodeChallenge = request.CodeChallenge, + ChallengeMethod = request.ChallengeMethod, + Device = request.Device, + RedirectUri = request.RedirectUri, + ClientProfile = auth.ClientProfile, + Tenant = auth.Tenant + }, + ctx.RequestAborted); + + return Results.Ok(new PkceAuthorizeResponse + { + AuthorizationCode = result.AuthorizationCode, + ExpiresIn = result.ExpiresIn + }); + } + + public async Task TryCompleteAsync(HttpContext ctx) + { + var authContext = _authContext.Current; + + if (authContext.FlowType != AuthFlowType.Login) + return Results.BadRequest("PKCE is only supported for login flow."); + + var request = await ReadPkceCompleteRequestAsync(ctx); + if (request is null) + return Results.BadRequest("Invalid PKCE payload."); + + if (string.IsNullOrWhiteSpace(request.AuthorizationCode) || string.IsNullOrWhiteSpace(request.CodeVerifier)) + return Results.BadRequest("authorization_code and code_verifier are required."); + + var artifactKey = new AuthArtifactKey(request.AuthorizationCode); + var artifact = await _authStore.GetAsync(artifactKey, ctx.RequestAborted) as PkceAuthorizationArtifact; + + if (artifact is null) + { + return Results.Ok(new TryPkceLoginResult + { + Success = false, + RetryWithNewPkce = true + }); + } + + var validation = _validator.Validate( + artifact, + request.CodeVerifier, + new PkceContextSnapshot( + clientProfile: artifact.Context.ClientProfile, + tenant: artifact.Context.Tenant, + redirectUri: artifact.Context.RedirectUri, + device: artifact.Context.Device), + _clock.UtcNow); + + if (!validation.Success) + { + return Results.Ok(new TryPkceLoginResult + { + Success = false, + RetryWithNewPkce = true + }); + } + + var loginRequest = new LoginRequest + { + Identifier = request.Identifier, + Secret = request.Secret, + RequestTokens = authContext.AllowsTokenIssuance + }; + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = artifact.Context.ClientProfile, + Device = artifact.Context.Device + }; + + var preview = await _internalFlowService.LoginAsync(authContext, execution, loginRequest, + new LoginExecutionOptions + { + Mode = LoginExecutionMode.Preview, + SuppressFailureAttempt = false, + SuppressSuccessReset = true + }, ctx.RequestAborted); + + return Results.Ok(new TryPkceLoginResult + { + Success = preview.IsSuccess, + Reason = preview.FailureReason, + RemainingAttempts = preview.RemainingAttempts, + LockoutUntilUtc = preview.LockoutUntilUtc, + RequiresMfa = preview.FailureReason == AuthFailureReason.RequiresMfa, + RetryWithNewPkce = false + }); + } + + public async Task CompleteAsync(HttpContext ctx) + { + var auth = _authContext.Current; + + var request = await ReadPkceCompleteRequestAsync(ctx); + if (request is null) + return Results.BadRequest("Invalid PKCE payload."); + + var result = await _pkceService.CompleteAsync( + auth, + new PkceCompleteRequest + { + AuthorizationCode = request.AuthorizationCode!, + CodeVerifier = request.CodeVerifier!, + Identifier = request.Identifier, + Secret = request.Secret + }, + ctx.RequestAborted); + + if (result.InvalidPkce) + return Results.Unauthorized(); + + if (!result.Success) + return await RedirectToLoginWithErrorAsync(ctx, auth, "invalid"); + + var login = result.LoginResult!; + + if (login.SessionId is not null) + _credentialResponseWriter.Write(ctx, GrantKind.Session, login.SessionId.Value); + + if (login.AccessToken is not null) + _credentialResponseWriter.Write(ctx, GrantKind.AccessToken, login.AccessToken); + + if (login.RefreshToken is not null) + _credentialResponseWriter.Write(ctx, GrantKind.RefreshToken, login.RefreshToken); + + var decision = _redirectResolver.ResolveSuccess(auth, ctx); + + return decision.Enabled + ? Results.Redirect(decision.TargetUrl!) + : Results.Ok(); + } + + private static async Task ReadPkceAuthorizeRequestAsync(HttpContext ctx) + { + if (ctx.Request.HasJsonContentType()) + { + return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted); + } + + if (ctx.Request.HasFormContentType) + { + var form = await ctx.GetCachedFormAsync(); + + var codeChallenge = form?["code_challenge"].ToString(); + var challengeMethod = form?["challenge_method"].ToString(); + var redirectUri = form?["redirect_uri"].ToString(); + + if (string.IsNullOrWhiteSpace(codeChallenge)) + throw new UAuthValidationException("code_challenge is required"); + + if (string.IsNullOrWhiteSpace(challengeMethod)) + throw new UAuthValidationException("challange_method is required"); + + var deviceRaw = form?["device"].FirstOrDefault(); + DeviceContext? device = null; + + if (!string.IsNullOrWhiteSpace(deviceRaw)) + { + try + { + var bytes = WebEncoders.Base64UrlDecode(deviceRaw); + var json = Encoding.UTF8.GetString(bytes); + device = JsonSerializer.Deserialize(json); + } + catch + { + device = null; + } + } + + if (device == null) + { + var info = await ctx.GetDeviceAsync(); + device = DeviceContext.Create(info.DeviceId, info.DeviceType, info.Platform, info.OperatingSystem, info.Browser, info.IpAddress); + } + + return new PkceAuthorizeRequest + { + CodeChallenge = codeChallenge, + ChallengeMethod = challengeMethod, + RedirectUri = string.IsNullOrWhiteSpace(redirectUri) ? null : redirectUri, + Device = device + }; + } + + return null; + } + + private static async Task ReadPkceCompleteRequestAsync(HttpContext ctx) + { + if (ctx.Request.HasJsonContentType()) + { + return await ctx.Request.ReadFromJsonAsync(cancellationToken: ctx.RequestAborted); + } + + if (ctx.Request.HasFormContentType) + { + var form = await ctx.GetCachedFormAsync(); + + var authorizationCode = form?["authorization_code"].FirstOrDefault(); + var codeVerifier = form?["code_verifier"].FirstOrDefault(); + var identifier = form?["Identifier"].FirstOrDefault(); + var secret = form?["Secret"].FirstOrDefault(); + var returnUrl = form?["return_url"].FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(authorizationCode)) + throw new UAuthValidationException("authorization_code is required"); + + if (string.IsNullOrWhiteSpace(codeVerifier)) + throw new UAuthValidationException("code_verifier is required"); + + return new PkceCompleteRequest + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier, + Identifier = identifier ?? string.Empty, + Secret = secret ?? string.Empty, + ReturnUrl = returnUrl ?? string.Empty + }; + } + + return null; + } + + private async Task RedirectToLoginWithErrorAsync(HttpContext ctx, AuthFlowContext auth, string error) + { + var basePath = auth.OriginalOptions.Hub.LoginPath ?? "/login"; + var hubKey = await ResolveHubKeyAsync(ctx); + + if (!string.IsNullOrWhiteSpace(hubKey)) + { + var key = new AuthArtifactKey(hubKey); + var artifact = await _authStore.GetAsync(key, ctx.RequestAborted); + + if (artifact is HubFlowArtifact hub) + { + hub.SetError(HubErrorCode.InvalidCredentials); + await _authStore.StoreAsync(key, hub); + + return Results.Redirect($"{basePath}?{UAuthConstants.Query.Hub}={Uri.EscapeDataString(hubKey)}"); + } + } + return Results.Redirect(basePath); + } + + private async Task ResolveHubKeyAsync(HttpContext ctx) + { + if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.Hub, out var q) && !string.IsNullOrWhiteSpace(q)) + return q.ToString(); + + if (ctx.Request.HasFormContentType) + { + var form = await ctx.GetCachedFormAsync(); + + if (form?.TryGetValue("hub_session_id", out var f) == true && !string.IsNullOrWhiteSpace(f)) + return f.ToString(); + } + + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs new file mode 100644 index 00000000..bc59d6b0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/RefreshEndpointHandler.cs @@ -0,0 +1,81 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Services; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +public sealed class RefreshEndpointHandler : IRefreshEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authContext; + private readonly IRefreshFlowService _refreshFlow; + private readonly ICredentialResponseWriter _credentialWriter; + private readonly IRefreshResponseWriter _refreshWriter; + private readonly IRefreshTokenResolver _refreshTokenResolver; + private readonly IRefreshResponsePolicy _refreshPolicy; + + public RefreshEndpointHandler( + IAuthFlowContextAccessor authContext, + IRefreshFlowService refreshFlow, + ICredentialResponseWriter credentialWriter, + IRefreshResponseWriter refreshWriter, + IRefreshTokenResolver refreshTokenResolver, + IRefreshResponsePolicy refreshPolicy) + { + _authContext = authContext; + _refreshFlow = refreshFlow; + _credentialWriter = credentialWriter; + _refreshWriter = refreshWriter; + _refreshTokenResolver = refreshTokenResolver; + _refreshPolicy = refreshPolicy; + } + + public async Task RefreshAsync(HttpContext ctx) + { + var flow = _authContext.Current; + + if (flow == null) + { + return Results.BadRequest("No AuthFlowContext is found."); + } + + var request = new RefreshFlowRequest + { + SessionId = flow.Session?.SessionId, + RefreshToken = _refreshTokenResolver.Resolve(ctx), + Device = flow.Device, + }; + + var result = await _refreshFlow.RefreshAsync(flow, request, ctx.RequestAborted); + + if (!result.Succeeded) + { + return Results.Unauthorized(); + } + + var primary = _refreshPolicy.SelectPrimary(flow, request, result); + + if (primary == GrantKind.Session && result.SessionId is not null) + { + _credentialWriter.Write(ctx, GrantKind.Session, result.SessionId.Value); + } + else if (primary == GrantKind.AccessToken && result.AccessToken is not null) + { + _credentialWriter.Write(ctx, GrantKind.AccessToken, result.AccessToken); + } + + if (_refreshPolicy.WriteRefreshToken(flow) && result.RefreshToken is not null) + { + _credentialWriter.Write(ctx, GrantKind.RefreshToken, result.RefreshToken); + } + + if (flow.OriginalOptions.Diagnostics.EnableRefreshDetails) + { + _refreshWriter.Write(ctx, result.Outcome); + } + return Results.NoContent(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs new file mode 100644 index 00000000..3fb62d3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/SessionEndpointHandler.cs @@ -0,0 +1,204 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Services; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class SessionEndpointHandler : ISessionEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ISessionApplicationService _sessions; + + public SessionEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ISessionApplicationService sessions) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _sessions = sessions; + } + + public async Task GetMyChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new PageRequest(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.GetUserChainsAsync(access, flow.UserKey!.Value, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetMyChainDetailAsync(SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.GetChainSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.GetUserChainDetailAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeMyChainAsync(SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + var result = await _sessions.RevokeUserChainAsync(access, flow.UserKey!.Value, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeOtherChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated || flow.Session == null) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeOtherChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeOtherChainsAsync(access, flow.UserKey!.Value, flow.Session.ChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeAllMyChainsAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsSelf, + resource: "sessions", + resourceId: flow.UserKey?.Value); + + await _sessions.RevokeAllChainsAsync(access, flow.UserKey!.Value, null, ctx.RequestAborted); + return Results.Ok(); + } + + + public async Task GetUserChainsAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new PageRequest(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.ListChainsAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.GetUserChainsAsync(access, userKey, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.GetChainAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.GetUserChainDetailAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeSessionAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeUserSessionAsync(access, userKey, sessionId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeUserChainAsync(UserKey userKey, SessionChainId chainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeChainAdmin, + resource: "sessions", + resourceId: userKey.Value); + + var result = await _sessions.RevokeUserChainAsync(access, userKey, chainId, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeAllChainsAsync(UserKey userKey, SessionChainId? exceptChainId, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeAllChainsAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeAllChainsAsync(access, userKey, exceptChainId, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RevokeRootAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var access = await _accessContextFactory.CreateAsync( + flow, + UAuthActions.Sessions.RevokeRootAdmin, + resource: "sessions", + resourceId: userKey.Value); + + await _sessions.RevokeRootAsync(access, userKey, ctx.RequestAborted); + return Results.Ok(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs new file mode 100644 index 00000000..fa1d0120 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/UAuthEndpointRegistrar.cs @@ -0,0 +1,404 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +// TODO: Add Scalar/Swagger integration +// TODO: Add endpoint based guards +public class UAuthEndpointRegistrar : IAuthEndpointRegistrar +{ + private readonly UAuthServerEndpointOptions _options; + public UAuthEndpointRegistrar(IOptions options) + { + _options = options.Value.Endpoints; + } + + bool Enabled(string action) => !_options.DisabledActions.Contains(action); + + // NOTE: + // All endpoints intentionally use POST to avoid caching, + // CSRF ambiguity, and credential leakage via query strings. + public void MapEndpoints(RouteGroupBuilder rootGroup, UAuthServerOptions options) + { + // Default base: /auth + string basePrefix = options.Endpoints.BasePath.TrimStart('/'); + bool useRouteTenant = options.MultiTenant.Enabled && options.MultiTenant.EnableRoute; + + RouteGroupBuilder group = useRouteTenant + ? rootGroup.MapGroup("/{tenant}/" + basePrefix) + : rootGroup.MapGroup("/" + basePrefix); + + group.AddEndpointFilter(); + + //var user = group.MapGroup(""); + var self = group.MapGroup("/me"); + var admin = group.MapGroup("/admin"); + + if (options.Endpoints.Authentication != false) + { + group.MapPost("/login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) + => await h.LoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + + group.MapPost("/try-login", async ([FromServices] ILoginEndpointHandler h, HttpContext ctx) + => await h.TryLoginAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + + group.MapPost("/validate", async ([FromServices] IValidateEndpointHandler h, HttpContext ctx) + => await h.ValidateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.ValidateSession)); + + if (Enabled(UAuthActions.Flows.LogoutSelf)) + group.MapPost("/logout", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutDeviceSelf)) + self.MapPost("/logout-device", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutDeviceSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutOthersSelf)) + self.MapPost("/logout-others", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutOthersSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutAllSelf)) + self.MapPost("/logout-all", async ([FromServices] ILogoutEndpointHandler h, HttpContext ctx) + => await h.LogoutAllSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + group.MapPost("/refresh", async ([FromServices] IRefreshEndpointHandler h, HttpContext ctx) + => await h.RefreshAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshSession)); + + group.MapPost("/reauth", async ([FromServices] IReauthEndpointHandler h, HttpContext ctx) + => await h.ReauthAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Reauthentication)); + + + if (Enabled(UAuthActions.Flows.LogoutDeviceAdmin)) + admin.MapPost("/users/{userKey}/logout-device", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutDeviceAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutOthersAdmin)) + admin.MapPost("/users/{userKey}/logout-others", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutOthersAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + + if (Enabled(UAuthActions.Flows.LogoutAllAdmin)) + admin.MapPost("/users/{userKey}/logout-all", async ([FromServices] ILogoutEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.LogoutAllAdminAsync(ctx, userKey)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Logout)); + } + + if (options.Endpoints.Pkce != false) + { + var pkce = group.MapGroup("/pkce"); + + pkce.MapPost("/authorize", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.AuthorizeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + + pkce.MapPost("/complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.CompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + + pkce.MapPost("/try-complete", async ([FromServices] IPkceEndpointHandler h, HttpContext ctx) + => await h.TryCompleteAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.Login)); + } + + //if (options.Endpoints.Token != false) + //{ + // var token = group.MapGroup(""); + + // token.MapPost("/token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.GetTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IssueToken)); + + // token.MapPost("/refresh-token", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.RefreshTokenAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RefreshToken)); + + // token.MapPost("/introspect", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.IntrospectAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.IntrospectToken)); + + // token.MapPost("/revoke", async ([FromServices] ITokenEndpointHandler h, HttpContext ctx) + // => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeToken)); + //} + + if (options.Endpoints.Session != false) + { + var selfSession = self.MapGroup("/sessions"); + var adminSession = admin.MapGroup("/users/{userKey}/sessions"); + + if (Enabled(UAuthActions.Sessions.ListChainsSelf)) + selfSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.GetMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + if (Enabled(UAuthActions.Sessions.GetChainSelf)) + selfSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + => await h.GetMyChainDetailAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + if (Enabled(UAuthActions.Sessions.RevokeChainSelf)) + selfSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, SessionChainId chainId, HttpContext ctx) + => await h.RevokeMyChainAsync(chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeOtherChainsSelf)) + selfSession.MapPost("/revoke-others",async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.RevokeOtherChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeAllChainsSelf)) + selfSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, HttpContext ctx) + => await h.RevokeAllMyChainsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + + if (Enabled(UAuthActions.Sessions.ListChainsAdmin)) + adminSession.MapPost("/chains", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserChainsAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + if (Enabled(UAuthActions.Sessions.GetChainAdmin)) + adminSession.MapPost("/chains/{chainId}", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + => await h.GetUserChainDetailAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.QuerySession)); + + if (Enabled(UAuthActions.Sessions.RevokeSessionAdmin)) + adminSession.MapPost("/{sessionId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, AuthSessionId sessionId, HttpContext ctx) + => await h.RevokeUserSessionAsync(userKey, sessionId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeChainAdmin)) + adminSession.MapPost("/chains/{chainId}/revoke", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, SessionChainId chainId, HttpContext ctx) + => await h.RevokeUserChainAsync(userKey, chainId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeRootAdmin)) + adminSession.MapPost("/revoke-root", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeRootAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + + if (Enabled(UAuthActions.Sessions.RevokeAllChainsAdmin)) + adminSession.MapPost("/revoke-all", async ([FromServices] ISessionEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeAllChainsAsync(userKey, null, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.RevokeSession)); + } + + //if (options.EnableUserInfoEndpoints != false) + //{ + // user.MapPost("/userinfo", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.GetUserInfoAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserInfo)); + + // user.MapPost("/permissions", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.GetPermissionsAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + + // user.MapPost("/permissions/check", async ([FromServices] IUserInfoEndpointHandler h, HttpContext ctx) + // => await h.CheckPermissionAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.PermissionQuery)); + //} + + var adminUsers = admin.MapGroup("/users"); + + if (options.Endpoints.UserLifecycle != false) + { + if (Enabled(UAuthActions.Users.CreateAnonymous)) + group.MapPost("/users/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + if (Enabled(UAuthActions.Users.ChangeStatusSelf)) + self.MapPost("/status", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.ChangeStatusSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + if (Enabled(UAuthActions.Users.DeleteSelf)) + self.MapPost("/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + + if (Enabled(UAuthActions.Users.QueryAdmin)) + adminUsers.MapPost("/query", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.QueryUsersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + if (Enabled(UAuthActions.Users.CreateAdmin)) + adminUsers.MapPost("/create", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.CreateAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + if (Enabled(UAuthActions.Users.ChangeStatusAdmin)) + adminUsers.MapPost("/{userKey}/status", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.ChangeStatusAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + + if (Enabled(UAuthActions.Users.DeleteAdmin)) + adminUsers.MapPost("/{userKey}/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserManagement)); + } + + if (options.Endpoints.UserProfile != false) + { + if (Enabled(UAuthActions.UserProfiles.GetSelf)) + self.MapPost("/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + if (Enabled(UAuthActions.UserProfiles.UpdateSelf)) + self.MapPost("/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateMeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + + if (Enabled(UAuthActions.UserProfiles.GetAdmin)) + adminUsers.MapPost("/{userKey}/profile/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + + if (Enabled(UAuthActions.UserProfiles.UpdateAdmin)) + adminUsers.MapPost("/{userKey}/profile/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserProfileManagement)); + } + + if (options.Endpoints.UserIdentifier != false) + { + if (Enabled(UAuthActions.UserIdentifiers.GetSelf)) + self.MapPost("/identifiers/get", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.GetMyIdentifiersAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.AddSelf)) + self.MapPost("/identifiers/add", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.AddUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.UpdateSelf)) + self.MapPost("/identifiers/update", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UpdateUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.SetPrimarySelf)) + self.MapPost("/identifiers/set-primary",async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.SetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimarySelf)) + self.MapPost("/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.VerifySelf)) + self.MapPost("/identifiers/verify", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.VerifyUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.DeleteSelf)) + self.MapPost("/identifiers/delete", async ([FromServices] IUserEndpointHandler h, HttpContext ctx) + => await h.DeleteUserIdentifierSelfAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + + if (Enabled(UAuthActions.UserIdentifiers.GetAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/get", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserIdentifiersAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.AddAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/add", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.UpdateAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/update", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UpdateUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.SetPrimaryAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/set-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.SetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.UnsetPrimaryAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/unset-primary", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.UnsetPrimaryUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.VerifyAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/verify", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.VerifyUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + + if (Enabled(UAuthActions.UserIdentifiers.DeleteAdmin)) + adminUsers.MapPost("/{userKey}/identifiers/delete", async ([FromServices] IUserEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteUserIdentifierAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.UserIdentifierManagement)); + } + + if (options.Endpoints.Credentials != false) + { + var selfCredentials = self.MapGroup("/credentials"); + var adminCredentials = admin.MapGroup("/users/{userKey}/credentials"); + + if (Enabled(UAuthActions.Credentials.AddSelf)) + selfCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.AddAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.ChangeSelf)) + selfCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.ChangeSecretAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.RevokeSelf)) + selfCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.RevokeAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.BeginResetAnonymous)) + selfCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.BeginResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.CompleteResetAnonymous)) + selfCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, HttpContext ctx) + => await h.CompleteResetAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + + if (Enabled(UAuthActions.Credentials.AddAdmin)) + adminCredentials.MapPost("/add", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AddAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.ChangeAdmin)) + adminCredentials.MapPost("/change", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.ChangeSecretAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.RevokeAdmin)) + adminCredentials.MapPost("/revoke", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RevokeAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.BeginResetAdmin)) + adminCredentials.MapPost("/reset/begin", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.BeginResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.CompleteResetAdmin)) + adminCredentials.MapPost("/reset/complete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.CompleteResetAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + + if (Enabled(UAuthActions.Credentials.DeleteAdmin)) + adminCredentials.MapPost("/delete", async ([FromServices] ICredentialEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.DeleteAdminAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.CredentialManagement)); + } + + if (options.Endpoints.Authorization != false) + { + var selfAuthz = self.MapGroup("/authorization"); + var adminAuthz = admin.MapGroup("/authorization"); + + // TODO: Add enabled actions here + selfAuthz.MapPost("/check", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.CheckAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.GetSelf)) + selfAuthz.MapPost("/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.GetMyRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + + if (Enabled(UAuthActions.Authorization.Roles.GetAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/get", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.GetUserRolesAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.AssignAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/assign", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.AssignRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.RemoveAdmin)) + adminAuthz.MapPost("/users/{userKey}/roles/remove", async ([FromServices] IAuthorizationEndpointHandler h, UserKey userKey, HttpContext ctx) + => await h.RemoveRoleAsync(userKey, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.CreateAdmin)) + adminAuthz.MapPost("/roles/create",async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.CreateRoleAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.QueryAdmin)) + adminAuthz.MapPost("/roles/query", async ([FromServices] IAuthorizationEndpointHandler h, HttpContext ctx) + => await h.QueryRolesAsync(ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.RenameAdmin)) + adminAuthz.MapPost("/roles/{roleId}/rename", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) + => await h.RenameRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.SetPermissionsAdmin)) + adminAuthz.MapPost("/roles/{roleId}/permissions", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) + => await h.SetRolePermissionsAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + + if (Enabled(UAuthActions.Authorization.Roles.DeleteAdmin)) + adminAuthz.MapPost("/roles/{roleId}/delete", async ([FromServices] IAuthorizationEndpointHandler h, RoleId roleId, HttpContext ctx) + => await h.DeleteRoleAsync(roleId, ctx)).WithMetadata(new AuthFlowMetadata(AuthFlowType.AuthorizationManagement)); + } + + // IMPORTANT: + // Escape hatch is invoked AFTER all UltimateAuth endpoints are registered. + // Developers may add metadata, filters, authorization, rate limits, etc. + // Removing or remapping UltimateAuth endpoints is unsupported. + options.OnConfigureEndpoints?.Invoke(rootGroup); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs new file mode 100644 index 00000000..9473ee3a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Endpoints/ValidateEndpointHandler.cs @@ -0,0 +1,103 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Endpoints; + +internal sealed class ValidateEndpointHandler : IValidateEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authContext; + private readonly IValidateCredentialResolver _credentialResolver; + private readonly ISessionValidator _sessionValidator; + private readonly IAuthStateSnapshotFactory _snapshotFactory; + private readonly IClock _clock; + + public ValidateEndpointHandler( + IAuthFlowContextAccessor authContext, + IValidateCredentialResolver credentialResolver, + ISessionValidator sessionValidator, + IAuthStateSnapshotFactory snapshotFactory, + IClock clock) + { + _authContext = authContext; + _credentialResolver = credentialResolver; + _sessionValidator = sessionValidator; + _snapshotFactory = snapshotFactory; + _clock = clock; + } + + public async Task ValidateAsync(HttpContext context, CancellationToken ct = default) + { + var auth = _authContext.Current; + var credential = await _credentialResolver.ResolveAsync(context, auth.Response); + + if (credential is null) + { + return Results.Json( + new AuthValidationResult + { + State = SessionState.NotFound + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + if (credential.Kind == PrimaryGrantKind.Stateful) + { + if (!AuthSessionId.TryCreate(credential.Value, out var sessionId)) + { + return Results.Json( + new AuthValidationResult + { + State = SessionState.Invalid + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + var tenant = context.GetTenant(); + + var result = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = tenant, + SessionId = sessionId, + Now = _clock.UtcNow, + Device = auth.Device + }, + ct); + + if (result.UserKey is not UserKey userKey) + { + return Results.Json( + new AuthValidationResult + { + State = SessionState.Invalid + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } + + var snapshot = await _snapshotFactory.CreateAsync(result); + + return Results.Ok(new AuthValidationResult + { + State = result.IsValid ? SessionState.Active : result.State, + Snapshot = snapshot + }); + } + + // Stateless (JWT / Opaque) โ€“ 0.0.1 no support yet + return Results.Json( + new AuthValidationResult + { + State = SessionState.Unsupported + }, + statusCode: StatusCodes.Status401Unauthorized + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Events/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Events/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Events/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs new file mode 100644 index 00000000..d4ae11c5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFailureReasonExtensions.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class AuthFailureReasonExtensions +{ + public static string ToDefaultCode(this AuthFailureReason reason) + => reason switch + { + AuthFailureReason.InvalidCredentials => "invalid_credentials", + AuthFailureReason.LockedOut => "locked", + AuthFailureReason.RequiresMfa => "mfa_required", + AuthFailureReason.SessionExpired => "session_expired", + AuthFailureReason.SessionRevoked => "session_revoked", + AuthFailureReason.TenantDisabled => "tenant_disabled", + AuthFailureReason.Unauthorized => "unauthorized", + AuthFailureReason.ReauthenticationRequired => "reauthentication_required", + _ => "failed" + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs new file mode 100644 index 00000000..12ff84cf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowContextExtensions.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class AuthFlowContextExtensions +{ + public static AuthContext ToAuthContext(this AuthFlowContext flow, DateTimeOffset now) + { + return new AuthContext + { + ClientProfile = flow.ClientProfile, + Tenant = flow.Tenant, + Operation = flow.FlowType.ToAuthOperation(), + Mode = flow.EffectiveMode, + At = now, + Device = flow.Device, + Session = flow.Session + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs new file mode 100644 index 00000000..953ac704 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/AuthFlowTypeExtensions.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class AuthFlowTypeExtensions +{ + public static AuthOperation ToAuthOperation(this AuthFlowType flowType) + => flowType switch + { + AuthFlowType.Login => AuthOperation.Login, + AuthFlowType.Reauthentication => AuthOperation.Login, + + AuthFlowType.ValidateSession => AuthOperation.Access, + AuthFlowType.UserInfo => AuthOperation.Access, + AuthFlowType.PermissionQuery => AuthOperation.Access, + AuthFlowType.IssueToken => AuthOperation.Access, + AuthFlowType.IntrospectToken => AuthOperation.Access, + + AuthFlowType.RefreshSession => AuthOperation.Refresh, + AuthFlowType.RefreshToken => AuthOperation.Refresh, + + AuthFlowType.Logout => AuthOperation.Logout, + AuthFlowType.RevokeSession => AuthOperation.Revoke, + AuthFlowType.RevokeToken => AuthOperation.Revoke, + + AuthFlowType.ApiAccess => AuthOperation.ResourceAccess, + + AuthFlowType.QuerySession => AuthOperation.System, + + _ => throw new InvalidOperationException($"Unsupported flow type: {flowType}") + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs new file mode 100644 index 00000000..d0e17f40 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ClaimsSnapshotExtensions.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class ClaimsSnapshotExtensions +{ + public static IReadOnlyCollection AsClaims(this ClaimsSnapshot snapshot) + => snapshot.AsDictionary().Select(kv => new Claim(kv.Key, kv.Value)).ToArray(); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs new file mode 100644 index 00000000..bf1bf3cb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/EndpointRouteBuilderExtensions.cs @@ -0,0 +1,39 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class EndpointRouteBuilderExtensions +{ + public static IEndpointRouteBuilder MapUltimateAuthEndpoints(this IEndpointRouteBuilder endpoints) + { + var sp = endpoints.ServiceProvider; + + var registrar = sp.GetRequiredService(); + var options = sp.GetRequiredService>().Value; + + var marker = sp.GetService(); + var requiresCors = marker?.RequiresCors == true; + + var rootGroup = endpoints.MapGroup(""); + + if (requiresCors) + { + rootGroup = rootGroup.RequireCors("UAuthHub"); + } + + registrar.MapEndpoints(rootGroup, options); + + if (endpoints is WebApplication app) + { + options.OnConfigureEndpoints?.Invoke(app); + } + + return endpoints; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs new file mode 100644 index 00000000..3b93237f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextDeviceExtensions.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextDeviceExtensions +{ + public static async Task GetDeviceAsync(this HttpContext context) + { + var resolver = context.RequestServices.GetRequiredService(); + return await resolver.ResolveAsync(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs new file mode 100644 index 00000000..262f8aee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextJsonExtensions.cs @@ -0,0 +1,43 @@ +๏ปฟusing System.Text.Json; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextJsonExtensions +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + PropertyNameCaseInsensitive = true + }; + + public static async Task ReadJsonAsync(this HttpContext ctx, CancellationToken ct = default) + { + var request = ctx.Request; + + if (!request.HasJsonContentType()) + throw new InvalidOperationException("Request content type must be application/json."); + + if (request.Body == null || request.ContentLength == 0) + throw new InvalidOperationException("Request body is empty."); + + request.EnableBuffering(); + + request.Body.Position = 0; + + try + { + var result = await JsonSerializer.DeserializeAsync(request.Body, JsonOptions, ct); + + request.Body.Position = 0; + + if (result == null) + throw new InvalidOperationException("Request body could not be deserialized."); + + return result; + } + catch (JsonException) + { + throw new InvalidOperationException("Invalid JSON"); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs new file mode 100644 index 00000000..480e882b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextRequestExtensions.cs @@ -0,0 +1,34 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +internal static class HttpContextRequestExtensions +{ + private const string FormCacheKey = "__uauth_form"; + + public static async Task GetCachedFormAsync(this HttpContext ctx) + { + if (!ctx.Request.HasFormContentType) + return null; + + if (ctx.Items.TryGetValue(FormCacheKey, out var existing) && existing is IFormCollection cached) + return cached; + + try + { + ctx.Request.EnableBuffering(); + var form = await ctx.Request.ReadFormAsync(); + ctx.Request.Body.Position = 0; + ctx.Items[FormCacheKey] = form; + return form; + } + catch (IOException) + { + return null; + } + catch + { + throw new InvalidOperationException(); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs new file mode 100644 index 00000000..3ca9c43a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextReturnUrlExtensions.cs @@ -0,0 +1,34 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +internal static class HttpContextReturnUrlExtensions +{ + public static async Task GetReturnUrlAsync(this HttpContext ctx) + { + if (ctx.Request.Query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var query)) + { + return query.ToString(); + } + + if (ctx.Request.HasFormContentType) + { + try + { + var form = await ctx.GetCachedFormAsync(); + + if (form?.TryGetValue(UAuthConstants.Form.ReturnUrl, out var formValue) == true) + { + return formValue.ToString(); + } + } + catch (IOException) + { + return null; + } + } + + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs new file mode 100644 index 00000000..3cf61e3a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextSessionExtensions.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextSessionExtensions +{ + public static SessionContext GetSessionContext(this HttpContext context) + { + if (context.Items.TryGetValue(UAuthConstants.HttpItems.SessionContext, out var value) && value is SessionContext session) + { + return session; + } + + return SessionContext.Anonymous(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs new file mode 100644 index 00000000..69c58757 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextTenantExtensions.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextTenantExtensions +{ + public static TenantKey GetTenant(this HttpContext context) + { + if (!context.Items.TryGetValue(UAuthConstants.HttpItems.TenantContextKey, out var value) || value is not UAuthTenantContext tenantCtx) + { + throw new InvalidOperationException("TenantContext is missing. TenantMiddleware must run before authentication."); + } + + return tenantCtx.Tenant; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs new file mode 100644 index 00000000..ac88c5d7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/HttpContext/HttpContextUserExtensions.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class HttpContextUserExtensions +{ + public static AuthUserSnapshot GetUserContext(this HttpContext ctx) + { + if (ctx.Items.TryGetValue(UAuthConstants.HttpItems.UserContextKey, out var value) && value is AuthUserSnapshot user) + { + return user; + } + + return AuthUserSnapshot.Anonymous(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..a9da44d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,492 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Credentials; +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; +using CodeBeam.UltimateAuth.Server.Authorization; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.ResourceApi; +using CodeBeam.UltimateAuth.Server.Runtime; +using CodeBeam.UltimateAuth.Server.Security; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthServer(this IServiceCollection services, Action? configure = null, Action? configurePolicies = null) + { + ArgumentNullException.ThrowIfNull(services); + services.AddUltimateAuth(); + + AddUsersInternal(services); + AddCredentialsInternal(services); + AddAuthorizationInternal(services); + + services.AddUltimateAuthPolicies(configurePolicies); + + services.AddOptions() + // Program.cs configuration (lowest precedence) + .Configure(options => + { + configure?.Invoke(options); + }) + // appsettings.json (highest precedence) + .BindConfiguration("UltimateAuth:Server") + .PostConfigure(options => + { + // Add any default values or adjustments here if needed + }); + + services.AddUltimateAuthServerInternal(); + + return services; + } + + public static IServiceCollection AddUltimateAuthResourceApi(this IServiceCollection services, Action? configure = null, Action? configurePolicies = null) + { + ArgumentNullException.ThrowIfNull(services); + services.AddUltimateAuth(); + services.AddUltimateAuthPolicies(configurePolicies, isResourceApp: true); + + services.AddOptions() + .Configure(options => + { + configure?.Invoke(options); + }) + .BindConfiguration("UltimateAuth:ResourceApi"); + + services.AddUltimateAuthResourceInternal(); + + var temp = new UAuthResourceApiOptions(); + configure?.Invoke(temp); + + if (temp.AllowedClientOrigins?.Count > 0) + { + services.AddCors(cors => + { + cors.AddPolicy(temp.CorsPolicyName, policy => + { + policy + .WithOrigins(temp.AllowedClientOrigins.ToArray()) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); + }); + } + + return services; + } + + private static IServiceCollection AddUltimateAuthServerInternal(this IServiceCollection services) + { + services.AddSingleton(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddScoped(sp => + { + var keyProvider = sp.GetRequiredService(); + var key = keyProvider.Resolve(null); + return new HmacSha256TokenHasher(((SymmetricSecurityKey)key.Key).Key); + }); + + // ----------------------------- + // OPTIONS VALIDATION + // ----------------------------- + //services.TryAddEnumerable(ServiceDescriptor.Singleton, UAuthServerOptionsValidator>()); + services.AddSingleton, UAuthServerOptionsValidator>(); + services.AddSingleton, UAuthServerLoginOptionsValidator>(); + services.AddSingleton, UAuthServerSessionOptionsValidator>(); + services.AddSingleton, UAuthServerTokenOptionsValidator>(); + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + + // Events + services.AddSingleton(sp => + { + var options = sp.GetRequiredService>().Value; + return options.Events.Clone(); + }); + + services.AddSingleton(); + + // Tenant Resolution + services.TryAddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + + var resolvers = new List(); + + if (opts.EnableRoute) + resolvers.Add(new PathTenantResolver()); + + if (opts.EnableHeader) + resolvers.Add(new HeaderTenantResolver(opts.HeaderName)); + + if (opts.EnableDomain) + resolvers.Add(new HostTenantResolver()); + + return resolvers.Count switch + { + 0 => new NullTenantResolver(), + 1 => resolvers[0], + _ => new CompositeTenantResolver(resolvers) + }; + }); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddHttpContextAccessor(); + services.TryAddScoped, UAuthUserAccessor>(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + services.TryAddScoped(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddSingleton(); + services.TryAddScoped(); + services.TryAddSingleton(); + + services.TryAddScoped(); + + // Endpoints + services.TryAddScoped(); + services.TryAddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + // ASP.NET Core Integration + services.AddAuthentication(); + + services.PostConfigureAll(options => + { + options.DefaultAuthenticateScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultSignInScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultChallengeScheme ??= UAuthConstants.SchemeDefaults.GlobalScheme; + }); + + services.AddAuthentication().AddUAuthCookies(); + services.AddAuthorization(); + + services.AddSingleton(); + services.AddScoped(); + + services.Configure(opt => + { + opt.AllowedTypes = new HashSet + { + UserIdentifierType.Username, + UserIdentifierType.Email + }; + + opt.EnableCustomResolvers = true; + opt.CustomResolversFirst = true; + }); + + return services; + } + + internal static IServiceCollection AddUltimateAuthPolicies(this IServiceCollection services, Action? configure = null, bool isResourceApp = false) + { + if (services.Any(d => d.ServiceType == typeof(AccessPolicyRegistry))) + throw new InvalidOperationException("UltimateAuth policies already registered."); + + var registry = new AccessPolicyRegistry(); + + if (isResourceApp) + { + DefaultPolicySet.RegisterResource(registry); + } + else + { + DefaultPolicySet.RegisterServer(registry); + } + + configure?.Invoke(registry); + + services.AddSingleton(registry); + + services.AddSingleton(sp => + { + var r = sp.GetRequiredService(); + return r.Build(); + }); + + services.AddScoped(); + + services.TryAddScoped(sp => + { + var invariants = sp.GetServices(); + var globalPolicies = sp.GetServices(); + return new UAuthAccessAuthority(invariants, globalPolicies); + }); + + return services; + } + + // ========================= + // Users (Framework-Required) + // ========================= + internal static IServiceCollection AddUsersInternal(IServiceCollection services) + { + services.TryAddScoped(typeof(IUserAccessor), typeof(UAuthUserAccessor)); + services.TryAddScoped(); + services.TryAddScoped(); + return services; + } + + // ========================= + // Credentials (Framework-Required) + // ========================= + internal static IServiceCollection AddCredentialsInternal(IServiceCollection services) + { + services.TryAddScoped(); + return services; + } + + // ========================= + // Authorization (Framework-Required) + // ========================= + internal static IServiceCollection AddAuthorizationInternal(IServiceCollection services) + { + services.TryAddScoped(typeof(IUserClaimsProvider), typeof(AuthorizationClaimsProvider)); + return services; + } + + public static IServiceCollection AddUAuthHub(this IServiceCollection services, Action? configure = null) + { + services.PostConfigure(options => + { + configure?.Invoke(options.Hub); + }); + + services.TryAddSingleton(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddCors(options => + { + options.AddPolicy("UAuthHub", policy => + { + var sp = services.BuildServiceProvider(); + var serverOptions = sp.GetRequiredService>().Value; + + var origins = serverOptions.Hub.AllowedClientOrigins + .Select(OriginHelper.Normalize) + .ToArray(); + + if (origins.Length > 0) + { + policy + .WithOrigins(origins) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials() + .WithExposedHeaders("X-UAuth-Refresh"); + } + }); + }); + + return services; + } + + private static IServiceCollection AddUltimateAuthResourceInternal(this IServiceCollection services) + { + // Resource API Specific + services.AddSingleton(); + + services.AddScoped(); + services.AddScoped, ResourceUserAccessor>(); + services.AddScoped(); + services.AddScoped(); + + // Server & Resource API Shared + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.TryAddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.TryAddSingleton(); + + services.TryAddScoped(); + services.TryAddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + + var resolvers = new List(); + + if (opts.EnableRoute) + resolvers.Add(new PathTenantResolver()); + + if (opts.EnableHeader) + resolvers.Add(new HeaderTenantResolver(opts.HeaderName)); + + if (opts.EnableDomain) + resolvers.Add(new HostTenantResolver()); + + return resolvers.Count switch + { + 0 => new NullTenantResolver(), + 1 => resolvers[0], + _ => new CompositeTenantResolver(resolvers) + }; + }); + + // ASP.NET Core Integration + services.AddHttpContextAccessor(); + services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = UAuthConstants.SchemeDefaults.GlobalScheme; + options.DefaultChallengeScheme = UAuthConstants.SchemeDefaults.GlobalScheme; + }) + .AddUAuthResourceApi(); + services.AddAuthorization(); + + services.AddSingleton(); + services.AddScoped(); + + services.AddHttpClient((sp, client) => + { + var opts = sp.GetRequiredService>().Value; + + if (string.IsNullOrWhiteSpace(opts.UAuthHubBaseUrl)) + throw new InvalidOperationException("UAuthHubBaseUrl is not configured. Add it via UAuthResourceApiOptions."); + + client.BaseAddress = new Uri(opts.UAuthHubBaseUrl); + }); + + return services; + } +} + +internal sealed class NullTenantResolver : ITenantIdResolver +{ + public Task ResolveTenantIdAsync(TenantResolutionContext context) => Task.FromResult(null); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs new file mode 100644 index 00000000..d352d7f1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthApplicationBuilderExtensions.cs @@ -0,0 +1,77 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Middlewares; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UltimateAuthApplicationBuilderExtensions +{ + public static IApplicationBuilder UseUltimateAuth(this IApplicationBuilder app) + { + app.UseUAuthExceptionHandling(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + + return app; + } + + public static IApplicationBuilder UseUltimateAuthWithAspNetCore(this IApplicationBuilder app, bool? enableCors = null) + { + var logger = app.ApplicationServices + .GetRequiredService() + .CreateLogger("UltimateAuth"); + + var marker = app.ApplicationServices.GetService(); + var requiresCors = marker?.RequiresCors == true; + + if (enableCors == true || (enableCors == null && requiresCors)) + app.UseCors(); + + if (requiresCors && enableCors == false) + { + logger.LogWarning("UAuthHub requires CORS. Either call app.UseCors() or enable it via UseUltimateAuthWithAspNetCore(enableCors: true)."); + } + + app.UseUltimateAuth(); + app.UseAuthentication(); + app.UseAuthorization(); + return app; + } + + public static IApplicationBuilder UseUltimateAuthResourceApi(this IApplicationBuilder app) + { + var logger = app.ApplicationServices + .GetRequiredService() + .CreateLogger("UltimateAuth"); + + var options = app.ApplicationServices.GetRequiredService>().Value; + + if (options.AllowedClientOrigins?.Count > 0) + { + app.UseCors(options.CorsPolicyName); + logger.LogInformation("UAuth Resource API initialized with CORS."); + } + + app.UseUAuthExceptionHandling(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + app.UseMiddleware(); + + return app; + } + + public static IApplicationBuilder UseUltimateAuthResourceApiWithAspNetCore(this IApplicationBuilder app) + { + app.UseUltimateAuthResourceApi(); + app.UseAuthentication(); + app.UseAuthorization(); + + return app; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs new file mode 100644 index 00000000..6dd8d03a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthExceptionHandlingExtensions.cs @@ -0,0 +1,59 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Errors; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UAuthExceptionHandlingExtensions +{ + public static IApplicationBuilder UseUAuthExceptionHandling(this IApplicationBuilder app) + { + return app.Use(async (context, next) => + { + try + { + await next(); + } + catch (UAuthRuntimeException ex) + { + if (context.Response.HasStarted) + throw; + + await WriteProblemDetails(context, ex); + } + }); + } + + private static Task WriteProblemDetails(HttpContext context, UAuthRuntimeException ex) + { + var problem = new ProblemDetails + { + Title = ex.Title, + Detail = ex.Code, + Status = MapStatusCode(ex), + Type = $"{ex.TypePrefix}/{ex.Code}" + }; + + problem.Extensions["traceId"] = context.TraceIdentifier; + + context.Response.StatusCode = problem.Status ?? 500; + context.Response.ContentType = "application/problem+json"; + + return context.Response.WriteAsJsonAsync(problem); + } + + private static int MapStatusCode(UAuthRuntimeException ex) => + ex switch + { + UAuthAuthenticationException => StatusCodes.Status401Unauthorized, + UAuthAuthorizationException => StatusCodes.Status403Forbidden, + UAuthConflictException => StatusCodes.Status409Conflict, + UAuthValidationException => StatusCodes.Status400BadRequest, + UAuthUnauthorizedException => StatusCodes.Status401Unauthorized, + UAuthForbiddenException => StatusCodes.Status403Forbidden, + UAuthNotFoundException => StatusCodes.Status404NotFound, + UAuthChallengeRequiredException => StatusCodes.Status401Unauthorized, + _ => StatusCodes.Status400BadRequest + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs new file mode 100644 index 00000000..7aef3c12 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthHubEndpointExtensions.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UAuthHubEndpointExtensions +{ + public static IEndpointRouteBuilder MapUAuthHub(this IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/auth/uauthhub"); + group.MapPost("/entry", HandleHub.HandleHubEntry); + + return endpoints; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs new file mode 100644 index 00000000..d837ebfa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthRazorExtensions.cs @@ -0,0 +1,12 @@ +๏ปฟusing Microsoft.AspNetCore.Builder; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UAuthRazorExtensions +{ + public static RazorComponentsEndpointConventionBuilder AddUltimateAuthRoutes(this RazorComponentsEndpointConventionBuilder builder, Assembly[] clientAssembly) + { + return builder.AddAdditionalAssemblies(clientAssembly); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs new file mode 100644 index 00000000..07c268da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Extensions/UAuthServerOptionsExtensions.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Extensions; + +public static class UAuthServerOptionsExtensions +{ + public static void ConfigureMode(this UAuthServerOptions options, UAuthMode mode, Action configure) + { + options.ModeConfigurations[mode] = configure; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginAuthority.cs new file mode 100644 index 00000000..2c99ca03 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginAuthority.cs @@ -0,0 +1,16 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Represents the authority responsible for making login decisions. +/// This authority determines whether a login attempt is allowed, +/// denied, or requires additional verification (e.g. MFA). +/// +public interface ILoginAuthority +{ + /// + /// Evaluates a login attempt based on the provided decision context. + /// + /// The login decision context. + /// The login decision. + LoginDecision Decide(LoginDecisionContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs new file mode 100644 index 00000000..c2274f14 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/ILoginOrchestrator.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Orchestrates the login flow. +/// Responsible for executing the login process by coordinating +/// credential validation, user resolution, authority decision, +/// and session creation. +/// +public interface ILoginOrchestrator +{ + Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); +} + +internal interface IInternalLoginOrchestrator +{ + Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs new file mode 100644 index 00000000..29a97238 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginAuthority.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Default implementation of the login authority. +/// Applies basic security checks for login attempts. +/// +public sealed class LoginAuthority : ILoginAuthority +{ + public LoginDecision Decide(LoginDecisionContext context) + { + if (!context.UserExists || context.UserKey is null) + { + return LoginDecision.Deny(AuthFailureReason.InvalidCredentials); + } + + var state = context.SecurityState; + if (state is not null) + { + if (state.IsLocked(DateTimeOffset.UtcNow)) + return LoginDecision.Deny(AuthFailureReason.LockedOut); + + if (state.RequiresReauthentication) + return LoginDecision.Challenge(AuthFailureReason.ReauthenticationRequired); + } + + if (!context.CredentialsValid) + { + return LoginDecision.Deny(AuthFailureReason.InvalidCredentials); + } + + return LoginDecision.Allow(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs new file mode 100644 index 00000000..a04ddc2b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecision.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Represents the outcome of a login decision. +/// +public sealed class LoginDecision +{ + public LoginDecisionKind Kind { get; } + public AuthFailureReason? FailureReason { get; } + + + private LoginDecision(LoginDecisionKind kind, AuthFailureReason? reason = null) + { + Kind = kind; + FailureReason = reason; + } + + public static LoginDecision Allow() + => new(LoginDecisionKind.Allow); + + public static LoginDecision Deny(AuthFailureReason reason) + => new(LoginDecisionKind.Deny, reason); + + public static LoginDecision Challenge(AuthFailureReason reason) + => new(LoginDecisionKind.Challenge, reason); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs new file mode 100644 index 00000000..c88a9aa4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionContext.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Represents all information required by the login authority +/// to make a login decision. +/// +public sealed class LoginDecisionContext +{ + /// + /// Gets the tenant identifier. + /// + public TenantKey Tenant { get; init; } + + /// + /// Gets the login identifier (e.g. username or email). + /// + public required string Identifier { get; init; } + + /// + /// Indicates whether the provided credentials were successfully validated. + /// + public bool CredentialsValid { get; init; } + + /// + /// Gets the resolved user identifier if available. + /// + public UserKey? UserKey { get; init; } + + /// + /// Gets the user security state if the user could be resolved. + /// + //public IUserSecurityState? SecurityState { get; init; } + public AuthenticationSecurityState? SecurityState { get; init; } + + /// + /// Indicates whether the user exists. + /// This allows the authority to distinguish between + /// invalid credentials and non-existent users. + /// + public bool UserExists { get; init; } + + /// + /// Indicates whether this login attempt is part of a chained flow + /// (e.g. reauthentication, MFA completion). + /// + public bool IsChained { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs new file mode 100644 index 00000000..e4044bd7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginDecisionKind.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +public enum LoginDecisionKind +{ + Allow = 1, + Deny = 2, + Challenge = 3 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs new file mode 100644 index 00000000..56892530 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionMode.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +internal enum LoginExecutionMode +{ + Preview = 0, + Commit = 1 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs new file mode 100644 index 00000000..aeb960f4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginExecutionOptions.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed record LoginExecutionOptions +{ + public LoginExecutionMode Mode { get; init; } = LoginExecutionMode.Commit; + public bool SuppressFailureAttempt { get; init; } + public bool SuppressSuccessReset { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs new file mode 100644 index 00000000..54c018d5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginOrchestrator.cs @@ -0,0 +1,260 @@ +๏ปฟ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.Events; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Credentials; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Options; + +// TODO: Identifier-based throttling, Exponential lockout + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed class LoginOrchestrator : ILoginOrchestrator, IInternalLoginOrchestrator +{ + private readonly ILoginIdentifierResolver _identifierResolver; + private readonly IEnumerable _credentialProviders; // authentication + private readonly IUserRuntimeStateProvider _users; // eligible + private readonly ILoginAuthority _authority; + private readonly ISessionOrchestrator _sessionOrchestrator; + private readonly ITokenIssuer _tokens; + private readonly IUserClaimsProvider _claimsProvider; + private readonly ISessionStoreFactory _storeFactory; + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; // runtime risk + private readonly UAuthEventDispatcher _events; + private readonly UAuthServerOptions _options; + private readonly IClock _clock; + + public LoginOrchestrator( + ILoginIdentifierResolver identifierResolver, + IEnumerable credentialProviders, + IUserRuntimeStateProvider users, + ILoginAuthority authority, + ISessionOrchestrator sessionOrchestrator, + ITokenIssuer tokens, + IUserClaimsProvider claimsProvider, + ISessionStoreFactory storeFactory, + IAuthenticationSecurityManager authenticationSecurityManager, + UAuthEventDispatcher events, + IOptions options, + IClock clock) + { + _identifierResolver = identifierResolver; + _credentialProviders = credentialProviders; + _users = users; + _authority = authority; + _sessionOrchestrator = sessionOrchestrator; + _tokens = tokens; + _claimsProvider = claimsProvider; + _storeFactory = storeFactory; + _authenticationSecurityManager = authenticationSecurityManager; + _events = events; + _options = options.Value; + _clock = clock; + } + + public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + => LoginAsync(flow, request, new LoginExecutionOptions { Mode = LoginExecutionMode.Commit }, ct); + + public async Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (flow.Device.DeviceId is not DeviceId deviceId) + throw new UAuthConflictException("Device id could not resolved."); + + var now = _clock.UtcNow; + var resolution = await _identifierResolver.ResolveAsync(flow.Tenant, request.Identifier, ct); + var userKey = resolution?.UserKey; + + bool userExists = false; + bool credentialsValid = false; + + AuthenticationSecurityState? accountState = null; + AuthenticationSecurityState? factorState = null; + + if (userKey is not null) + { + var user = await _users.GetAsync(flow.Tenant, userKey.Value, ct); + if (user is not null && user.CanAuthenticate && !user.IsDeleted) + { + userExists = true; + accountState = await _authenticationSecurityManager.GetOrCreateAccountAsync(flow.Tenant, userKey.Value, ct); + + if (accountState.IsLocked(now)) + { + return LoginResult.Failed(AuthFailureReason.LockedOut, accountState.LockedUntil, remainingAttempts: 0); + } + + factorState = await _authenticationSecurityManager.GetOrCreateFactorAsync(flow.Tenant, userKey.Value, request.Factor, ct); + + if (factorState.IsLocked(now)) + { + return LoginResult.Failed(AuthFailureReason.LockedOut, factorState.LockedUntil, 0); + } + + foreach (var provider in _credentialProviders) + { + var credentials = await provider.GetByUserAsync(flow.Tenant, userKey.Value, ct); + + foreach (var credential in credentials) + { + if (credential.IsDeleted || !credential.Security.IsUsable(now)) + continue; + + if (await provider.ValidateAsync(credential, request.Secret, ct)) + { + credentialsValid = true; + break; + } + } + + if (credentialsValid) + break; + } + } + } + + // TODO: Add create-time uniqueness guard for chain id for concurrency + var sessionStore = _storeFactory.Create(flow.Tenant); + SessionChainId? chainId = null; + + if (userKey is not null) + { + var chain = await sessionStore.GetChainByDeviceAsync(userKey.Value, deviceId, ct); + + if (chain is not null && !chain.IsRevoked) + chainId = chain.ChainId; + } + + // TODO: Add accountState here, currently it only checks factor state + var decisionContext = new LoginDecisionContext + { + Tenant = flow.Tenant, + Identifier = request.Identifier, + CredentialsValid = credentialsValid, + UserExists = userExists, + UserKey = userKey, + SecurityState = factorState, + IsChained = chainId is not null + }; + + var decision = _authority.Decide(decisionContext); + + if (decision.Kind == LoginDecisionKind.Deny) + { + if (userKey is not null && userExists && factorState is not null) + { + DateTimeOffset? lockedUntil = null; + int? remainingAttempts = null; + + if (!loginExecution.SuppressFailureAttempt) + { + var version = factorState.SecurityVersion; + factorState = factorState.RegisterFailure(now, _options.Login.MaxFailedAttempts, _options.Login.LockoutDuration, _options.Login.ExtendLockOnFailure); + await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); + } + + if (_options.Login.IncludeFailureDetails) + { + var stateForResponse = factorState; + + if (stateForResponse.IsLocked(now)) + { + lockedUntil = stateForResponse.LockedUntil; + remainingAttempts = 0; + } + else if (_options.Login.MaxFailedAttempts > 0) + { + remainingAttempts = _options.Login.MaxFailedAttempts - stateForResponse.FailedAttempts; + } + } + + + return LoginResult.Failed( + factorState.IsLocked(now) + ? AuthFailureReason.LockedOut + : decision.FailureReason, + lockedUntil, + remainingAttempts); + } + + return LoginResult.Failed(decision.FailureReason); + } + + + if (decision.Kind == LoginDecisionKind.Challenge) + { + return LoginResult.Continue(new LoginContinuation + { + Type = LoginContinuationType.Mfa + }); + } + + if (!credentialsValid || userKey is null) + return LoginResult.Failed(AuthFailureReason.InvalidCredentials); + + // After this point, the login is successful. We can reset any failure counts and proceed to create a session. + if (loginExecution.Mode == LoginExecutionMode.Preview) + { + return LoginResult.SuccessPreview(); + } + + if (!loginExecution.SuppressSuccessReset && factorState is not null) + { + var version = factorState.SecurityVersion; + factorState = factorState.RegisterSuccess(); + await _authenticationSecurityManager.UpdateAsync(factorState, version, ct); + } + + var claims = await _claimsProvider.GetClaimsAsync(flow.Tenant, userKey.Value, ct); + + var sessionContext = new SessionIssuanceContext + { + Tenant = flow.Tenant, + UserKey = userKey.Value, + Now = now, + Device = flow.Device, + Claims = claims, + ChainId = chainId, + Metadata = SessionMetadata.Empty, + Mode = flow.EffectiveMode + }; + + var authContext = flow.ToAuthContext(now); + var issuedSession = await _sessionOrchestrator.ExecuteAsync(authContext, new CreateLoginSessionCommand(sessionContext), ct); + + AuthTokens? tokens = null; + + if (request.RequestTokens) + { + var tokenContext = new TokenIssuanceContext + { + Tenant = flow.Tenant, + UserKey = userKey.Value, + SessionId = issuedSession.Session.SessionId, + ChainId = issuedSession.Session.ChainId, + Claims = claims.AsDictionary() + }; + + tokens = new AuthTokens + { + AccessToken = await _tokens.IssueAccessTokenAsync(flow, tokenContext, ct), + RefreshToken = await _tokens.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.Persist, ct) + }; + } + + await _events.DispatchAsync( + new UserLoggedInContext(flow.Tenant, userKey.Value, now, flow.Device, issuedSession.Session.SessionId)); + + return LoginResult.Success(issuedSession.Session.SessionId, tokens); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs new file mode 100644 index 00000000..a32dc31f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Login/LoginPreviewFingerprint.cs @@ -0,0 +1,16 @@ +๏ปฟusing System.Security.Cryptography; +using System.Text; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal static class LoginPreviewFingerprint +{ + public static string Create(TenantKey tenant, string identifier, CredentialType factor, string secret, DeviceId deviceId) + { + var normalized = $"{tenant.Value}|{identifier}|{factor}|{deviceId.Value}|{secret}"; + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(normalized)); + return Convert.ToHexString(bytes); + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/IPkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/IPkceAuthorizationValidator.cs new file mode 100644 index 00000000..e0bafe9a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/IPkceAuthorizationValidator.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +public interface IPkceAuthorizationValidator +{ + PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs new file mode 100644 index 00000000..b9702d77 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationArtifact.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Represents a PKCE authorization process that has been initiated +/// but not yet completed. This artifact is short-lived, single-use, +/// and must be consumed atomically. +/// +public sealed class PkceAuthorizationArtifact : AuthArtifact +{ + public PkceAuthorizationArtifact( + AuthArtifactKey authorizationCode, + string codeChallenge, + PkceChallengeMethod challengeMethod, + DateTimeOffset expiresAt, + PkceContextSnapshot context) + : base(AuthArtifactType.PkceAuthorizationCode, expiresAt) + { + AuthorizationCode = authorizationCode; + CodeChallenge = codeChallenge; + ChallengeMethod = challengeMethod; + Context = context; + } + + /// + /// Opaque authorization code issued to the client. + /// This is the lookup key in the AuthStore. + /// + public AuthArtifactKey AuthorizationCode { get; } + + /// + /// Base64Url-encoded hashed code challenge (S256). + /// The original verifier is never stored. + /// + public string CodeChallenge { get; } + + public PkceChallengeMethod ChallengeMethod { get; } + + /// + /// Immutable snapshot of client and request context + /// at the time the PKCE flow was initiated. + /// + public PkceContextSnapshot Context { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs new file mode 100644 index 00000000..ac788f61 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizationValidator.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed class PkceAuthorizationValidator : IPkceAuthorizationValidator +{ + public PkceValidationResult Validate(PkceAuthorizationArtifact artifact, string codeVerifier, PkceContextSnapshot completionContext, DateTimeOffset now) + { + if (artifact.IsExpired(now)) + return PkceValidationResult.Fail(PkceValidationFailureReason.ArtifactExpired); + + if (!IsContextValid(artifact.Context, completionContext)) + return PkceValidationResult.Fail(PkceValidationFailureReason.ContextMismatch); + + if (artifact.ChallengeMethod != PkceChallengeMethod.S256) + return PkceValidationResult.Fail(PkceValidationFailureReason.UnsupportedChallengeMethod); + + if (!IsVerifierValid(codeVerifier, artifact.CodeChallenge)) + return PkceValidationResult.Fail(PkceValidationFailureReason.InvalidVerifier); + + return PkceValidationResult.Ok(); + } + + private static bool IsContextValid(PkceContextSnapshot original, PkceContextSnapshot completion) + { + if (!original.ClientProfile.Equals(completion.ClientProfile)) + return false; + + if (!string.Equals(original.Tenant, completion.Tenant, StringComparison.Ordinal)) + return false; + + if (!string.Equals(original.RedirectUri, completion.RedirectUri, StringComparison.Ordinal)) + return false; + + if (!Equals(original.Device, completion.Device)) + return false; + + return true; + } + + private static bool IsVerifierValid(string verifier, string expectedChallenge) + { + if (string.IsNullOrWhiteSpace(verifier)) + return false; + + using var sha256 = SHA256.Create(); + byte[] hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); + + string computedChallenge = Base64Url.Encode(hash); + + return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(computedChallenge), Encoding.ASCII.GetBytes(expectedChallenge)); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs new file mode 100644 index 00000000..2186a502 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceAuthorizeRequest.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed class PkceAuthorizeRequest +{ + public string CodeChallenge { get; init; } = default!; + public string ChallengeMethod { get; init; } = default!; + public string? RedirectUri { get; init; } + public required DeviceContext Device { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs new file mode 100644 index 00000000..287d8406 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceChallengeMethod.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +public enum PkceChallengeMethod +{ + S256 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs new file mode 100644 index 00000000..289d1b84 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceContextSnapshot.cs @@ -0,0 +1,47 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Immutable snapshot of relevant request and client context +/// captured at PKCE authorization time. +/// Used to ensure consistency and prevent flow confusion. +/// +public sealed class PkceContextSnapshot +{ + public PkceContextSnapshot( + UAuthClientProfile clientProfile, + TenantKey tenant, + string? redirectUri, + DeviceContext device) + { + ClientProfile = clientProfile; + Tenant = tenant; + RedirectUri = redirectUri; + Device = device; + } + + /// + /// Client profile resolved at runtime (e.g. BlazorWasm). + /// + public UAuthClientProfile ClientProfile { get; } + + /// + /// Tenant context at the time of authorization. + /// + public TenantKey Tenant { get; } + + /// + /// Redirect URI used during authorization. + /// Must match during completion. + /// + public string? RedirectUri { get; } + + /// + /// Optional device binding identifier. + /// Enables future hard-binding of PKCE flows to devices. + /// + public DeviceContext Device { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs new file mode 100644 index 00000000..29a6eef5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationFailureReason.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +public enum PkceValidationFailureReason +{ + None, + ArtifactExpired, + MaxAttemptsExceeded, + UnsupportedChallengeMethod, + InvalidVerifier, + ContextMismatch +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs new file mode 100644 index 00000000..a6c0dae9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Pkce/PkceValidationResult.cs @@ -0,0 +1,18 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +public sealed class PkceValidationResult +{ + private PkceValidationResult(bool success, PkceValidationFailureReason reason) + { + Success = success; + FailureReason = reason; + } + + public bool Success { get; } + + public PkceValidationFailureReason FailureReason { get; } + + public static PkceValidationResult Ok() => new(true, PkceValidationFailureReason.None); + + public static PkceValidationResult Fail(PkceValidationFailureReason reason) => new(false, reason); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs new file mode 100644 index 00000000..9d8e1515 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponsePolicy.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +public interface IRefreshResponsePolicy +{ + GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result); + bool WriteRefreshToken(AuthFlowContext flow); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponseWriter.cs new file mode 100644 index 00000000..9318e534 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshResponseWriter.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +public interface IRefreshResponseWriter +{ + void Write(HttpContext context, RefreshOutcome outcome); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshService.cs new file mode 100644 index 00000000..71c821e0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/IRefreshService.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Base contract for refresh-related services. +/// Refresh services renew authentication artifacts according to AuthMode. +/// +public interface IRefreshService +{ +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/ISessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/ISessionTouchService.cs new file mode 100644 index 00000000..fc16eef8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/ISessionTouchService.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Refreshes session lifecycle artifacts. +/// Used by PureOpaque and Hybrid modes. +/// +public interface ISessionTouchService : IRefreshService +{ + Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs new file mode 100644 index 00000000..00c9eb6d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecision.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Determines which authentication artifacts can be refreshed +/// for the current AuthMode. +/// This is a server-side decision and must be enforced centrally. +/// +public enum RefreshDecision +{ + /// + /// Refresh endpoint is disabled for this mode. + /// + NotSupported = 0, + + /// + /// Only session lifetime is extended. + /// No access / refresh token issued. + /// (PureOpaque) + /// + SessionTouch = 1, + + /// + /// Refresh token is rotated and + /// a new access token is issued. + /// Session MAY also be touched depending on policy. + /// (Hybrid, SemiHybrid, PureJwt) + /// + TokenRotation = 2 +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs new file mode 100644 index 00000000..8108d0bb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshDecisionResolver.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +/// +/// Resolves refresh behavior based on AuthMode. +/// This class is the single source of truth for refresh capability. +/// +public static class RefreshDecisionResolver +{ + public static RefreshDecision Resolve(UAuthMode mode) + { + return mode switch + { + UAuthMode.PureOpaque => RefreshDecision.SessionTouch, + + UAuthMode.Hybrid + or UAuthMode.SemiHybrid + or UAuthMode.PureJwt => RefreshDecision.TokenRotation, + + _ => RefreshDecision.NotSupported + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs new file mode 100644 index 00000000..f5a7f856 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshEvaluationResult.cs @@ -0,0 +1,5 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed record RefreshEvaluationResult(RefreshOutcome Outcome); diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs new file mode 100644 index 00000000..e29bc768 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponsePolicy.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal class RefreshResponsePolicy : IRefreshResponsePolicy +{ + public GrantKind SelectPrimary(AuthFlowContext flow, RefreshFlowRequest request, RefreshFlowResult result) + { + if (flow.EffectiveMode == UAuthMode.PureOpaque) + return GrantKind.Session; + + if (flow.EffectiveMode == UAuthMode.PureJwt) + return GrantKind.AccessToken; + + if (!string.IsNullOrWhiteSpace(request.RefreshToken) && request.SessionId == null) + { + return GrantKind.AccessToken; + } + + if (request.SessionId != null) + { + return GrantKind.Session; + } + + if (flow.ClientProfile == UAuthClientProfile.Api) + return GrantKind.AccessToken; + + return GrantKind.Session; + } + + + public bool WriteRefreshToken(AuthFlowContext flow) + { + if (flow.EffectiveMode != UAuthMode.PureOpaque) + return true; + + return false; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs new file mode 100644 index 00000000..46aaaf6c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshResponseWriter.cs @@ -0,0 +1,34 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed class RefreshResponseWriter : IRefreshResponseWriter +{ + private readonly UAuthDiagnosticsOptions _diagnostics; + + public RefreshResponseWriter(IOptions options) + { + _diagnostics = options.Value.Diagnostics; + } + + public void Write(HttpContext context, RefreshOutcome outcome) + { + if (!_diagnostics.EnableRefreshDetails) + return; + + context.Response.Headers[UAuthConstants.Headers.Refresh] = outcome switch + { + RefreshOutcome.NoOp => "no-op", + RefreshOutcome.Touched => "touched", + RefreshOutcome.Rotated => "rotated", + RefreshOutcome.ReauthRequired => "reauth-required", + RefreshOutcome.Success => "success", + _ => "unknown" + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs new file mode 100644 index 00000000..52da1aa4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshStrategyResolver.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using System.Security; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +public class RefreshStrategyResolver +{ + public static RefreshStrategy Resolve(UAuthMode mode) + { + return mode switch + { + UAuthMode.PureOpaque => RefreshStrategy.SessionOnly, + UAuthMode.PureJwt => RefreshStrategy.TokenOnly, + UAuthMode.SemiHybrid => RefreshStrategy.TokenWithSessionCheck, + UAuthMode.Hybrid => RefreshStrategy.SessionAndToken, + _ => throw new SecurityException("Unsupported refresh mode") + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs new file mode 100644 index 00000000..3140b965 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/RefreshTokenResolver.cs @@ -0,0 +1,40 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +internal sealed class RefreshTokenResolver : IRefreshTokenResolver +{ + private const string DefaultCookieName = "uar"; + private const string BearerPrefix = "Bearer "; + private const string RefreshHeaderName = "X-Refresh-Token"; + + public string? Resolve(HttpContext context) + { + if (context.Request.Cookies.TryGetValue(DefaultCookieName, out var cookieToken) && + !string.IsNullOrWhiteSpace(cookieToken)) + { + return cookieToken; + } + + if (context.Request.Headers.TryGetValue("Authorization", out StringValues authHeader)) + { + var value = authHeader.ToString(); + if (value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + var token = value.Substring(BearerPrefix.Length).Trim(); + if (!string.IsNullOrWhiteSpace(token)) + return token; + } + } + + if (context.Request.Headers.TryGetValue(RefreshHeaderName, out var headerToken) && + !string.IsNullOrWhiteSpace(headerToken)) + { + return headerToken.ToString(); + } + + return null; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs new file mode 100644 index 00000000..e72e8ad3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchPolicy.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Flows; + +public sealed class SessionTouchPolicy +{ + public TimeSpan? TouchInterval { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs new file mode 100644 index 00000000..2b29016e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Flows/Refresh/SessionTouchService.cs @@ -0,0 +1,48 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Flows; + +public sealed class SessionTouchService : ISessionTouchService +{ + private readonly ISessionStoreFactory _kernelFactory; + + public SessionTouchService(ISessionStoreFactory kernelFactory) + { + _kernelFactory = kernelFactory; + } + + // It's designed for PureOpaque sessions, which do not issue new refresh tokens on refresh. + // That's why the service access store direcly: There is no security flow here, only validate and touch session. + public async Task RefreshAsync(SessionValidationResult validation, SessionTouchPolicy policy, SessionTouchMode sessionTouchMode, DateTimeOffset now, CancellationToken ct = default) + { + if (!validation.IsValid || validation.ChainId is null) + return SessionRefreshResult.ReauthRequired(); + + if (!policy.TouchInterval.HasValue) + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch: false); + + var kernel = _kernelFactory.Create(validation.Tenant); + + bool didTouch = false; + + await kernel.ExecuteAsync(async _ => + { + var chain = await kernel.GetChainAsync(validation.ChainId.Value); + + if (chain is null || chain.IsRevoked) + return; + + if (now - chain.LastSeenAt < policy.TouchInterval.Value) + return; + + var expectedVersion = chain.Version; + var touched = chain.Touch(now); + + await kernel.SaveChainAsync(touched, expectedVersion); + didTouch = true; + }, ct); + + return SessionRefreshResult.Success(validation.SessionId!.Value, didTouch); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs new file mode 100644 index 00000000..0a991193 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/AccessPolicyProvider.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Abstractions; +using CodeBeam.UltimateAuth.Policies.Defaults; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class AccessPolicyProvider : IAccessPolicyProvider +{ + private readonly CompiledAccessPolicySet _set; + private readonly IServiceProvider _services; + + public AccessPolicyProvider(CompiledAccessPolicySet set, IServiceProvider services) + { + _set = set; + _services = services; + } + + public IReadOnlyCollection GetPolicies(AccessContext context) => _set.Resolve(context, _services); + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs new file mode 100644 index 00000000..13582524 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookieManager.cs @@ -0,0 +1,12 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUAuthCookieManager +{ + void Write(HttpContext context, string name, string value, CookieOptions options); + + bool TryRead(HttpContext context, string name, out string value); + + void Delete(HttpContext context, string name); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs new file mode 100644 index 00000000..786498ed --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/IUAuthCookiePolicyBuilder.cs @@ -0,0 +1,11 @@ +๏ปฟusing Microsoft.AspNetCore.Http; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUAuthCookiePolicyBuilder +{ + CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs new file mode 100644 index 00000000..42067c60 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookieManager.cs @@ -0,0 +1,21 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class UAuthCookieManager : IUAuthCookieManager +{ + public void Write(HttpContext context, string name, string value, CookieOptions options) + { + context.Response.Cookies.Append(name, value, options); + } + + public bool TryRead(HttpContext context, string name, out string value) + { + return context.Request.Cookies.TryGetValue(name, out value!); + } + + public void Delete(HttpContext context, string name) + { + context.Response.Cookies.Delete(name); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs new file mode 100644 index 00000000..78b0af0a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Cookies/UAuthCookiePolicyBuilder.cs @@ -0,0 +1,93 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class UAuthCookiePolicyBuilder : IUAuthCookiePolicyBuilder +{ + public CookieOptions Build(CredentialResponseOptions response, AuthFlowContext context, GrantKind kind) + { + if (response.Cookie is null) + throw new InvalidOperationException("Cookie policy requested but Cookie options are null."); + + var src = response.Cookie; + + var options = new CookieOptions + { + HttpOnly = src.HttpOnly, + Secure = src.SecurePolicy == CookieSecurePolicy.Always, + Path = src.Path, + Domain = src.Domain, + SameSite = ResolveSameSite(src, context) + }; + + ApplyLifetime(options, src, context, kind); + + return options; + } + + private static SameSiteMode ResolveSameSite(UAuthCookieOptions cookie, AuthFlowContext context) + { + if (cookie.SameSite is not null) + return cookie.SameSite.Value; + + return context.OriginalOptions.HubDeploymentMode switch + { + UAuthHubDeploymentMode.Embedded => SameSiteMode.Strict, + UAuthHubDeploymentMode.Integrated => SameSiteMode.Lax, + UAuthHubDeploymentMode.External => SameSiteMode.None, + _ => SameSiteMode.Lax + }; + } + + private static void ApplyLifetime(CookieOptions target, UAuthCookieOptions src, AuthFlowContext context, GrantKind kind) + { + var buffer = src.Lifetime.IdleBuffer ?? TimeSpan.Zero; + var baseLifetime = ResolveBaseLifetime(context, kind, src); + + if (baseLifetime is not null) + { + target.MaxAge = baseLifetime.Value + buffer; + } + } + + private static TimeSpan? ResolveBaseLifetime(AuthFlowContext context, GrantKind kind, UAuthCookieOptions src) + { + if (src.MaxAge is not null) + return src.MaxAge; + + if (src.Lifetime.AbsoluteLifetimeOverride is not null) + return src.Lifetime.AbsoluteLifetimeOverride; + + return kind switch + { + GrantKind.Session => ResolveSessionLifetime(context), + GrantKind.RefreshToken => context.EffectiveOptions.Options.Token.RefreshTokenLifetime, + GrantKind.AccessToken => context.EffectiveOptions.Options.Token.AccessTokenLifetime, + _ => null + }; + } + + private static TimeSpan? ResolveSessionLifetime(AuthFlowContext context) + { + var sessionIdle = context.EffectiveOptions.Options.Session.IdleTimeout; + var refresh = context.EffectiveOptions.Options.Token.RefreshTokenLifetime; + + return context.EffectiveMode switch + { + UAuthMode.PureOpaque => sessionIdle, + UAuthMode.Hybrid => Max(sessionIdle, refresh), + _ => sessionIdle + }; + } + + private static TimeSpan? Max(TimeSpan? a, TimeSpan? b) + { + if (a is null) return b; + if (b is null) return a; + return a > b ? a : b; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs new file mode 100644 index 00000000..a14f8437 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/ITransportCredentialResolver.cs @@ -0,0 +1,8 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface ITransportCredentialResolver +{ + ValueTask ResolveAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs new file mode 100644 index 00000000..aac3255f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredential.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class TransportCredential +{ + public required TransportCredentialKind Kind { get; init; } + public required string Value { get; init; } + + public string? TenantId { get; init; } + public required DeviceInfo Device { get; init; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs new file mode 100644 index 00000000..a33ad8bd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialKind.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum TransportCredentialKind +{ + Session, + AccessToken, + RefreshToken, + Hub +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs new file mode 100644 index 00000000..2751b2a3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/AspNetCore/TransportCredentialResolver.cs @@ -0,0 +1,124 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class TransportCredentialResolver : ITransportCredentialResolver +{ + private readonly IOptionsMonitor _server; + + public TransportCredentialResolver(IOptionsMonitor server) + { + _server = server; + } + + public async ValueTask ResolveAsync(HttpContext context) + { + var cookies = _server.CurrentValue.Cookie; + + return await TryFromAuthorizationHeaderAsync(context) + ?? await TryFromCookiesAsync(context, cookies) + ?? await TryFromQueryAsync(context) + ?? await TryFromBodyAsync(context) + ?? await TryFromHubAsync(context); + } + + // TODO: Make scheme configurable, shouldn't be hard coded + private static async ValueTask TryFromAuthorizationHeaderAsync(HttpContext ctx) + { + if (!ctx.Request.Headers.TryGetValue("Authorization", out var header)) + return null; + + var value = header.ToString(); + if (!value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var token = value["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(token)) + return null; + + return new TransportCredential + { + Kind = TransportCredentialKind.AccessToken, + Value = token, + TenantId = ctx.GetTenant().Value, + Device = await ctx.GetDeviceAsync() + }; + } + + private static async ValueTask TryFromCookiesAsync(HttpContext ctx, UAuthCookiePolicyOptions cookieSet) + { + if (TryReadCookie(ctx, cookieSet.Session.Name, out var session)) + return await BuildAsync(ctx, TransportCredentialKind.Session, session); + + if (TryReadCookie(ctx, cookieSet.RefreshToken.Name, out var refresh)) + return await BuildAsync(ctx, TransportCredentialKind.RefreshToken, refresh); + + if (TryReadCookie(ctx, cookieSet.AccessToken.Name, out var access)) + return await BuildAsync(ctx, TransportCredentialKind.AccessToken, access); + + return null; + } + + private static async ValueTask TryFromQueryAsync(HttpContext ctx) + { + if (!ctx.Request.Query.TryGetValue("access_token", out var token)) + return null; + + var value = token.ToString(); + if (string.IsNullOrWhiteSpace(value)) + return null; + + return new TransportCredential + { + Kind = TransportCredentialKind.AccessToken, + Value = value, + TenantId = ctx.GetTenant().Value, + Device = await ctx.GetDeviceAsync() + }; + } + + private static ValueTask TryFromBodyAsync(HttpContext ctx) + { + // intentionally empty for now + // body parsing is expensive and opt-in later + + return ValueTask.FromResult(null); + } + + private static ValueTask TryFromHubAsync(HttpContext ctx) + { + // UAuthHub detection can live here later + + return ValueTask.FromResult(null); + } + + private static bool TryReadCookie(HttpContext ctx, string name, out string value) + { + value = string.Empty; + + if (string.IsNullOrWhiteSpace(name)) + return false; + + if (!ctx.Request.Cookies.TryGetValue(name, out var raw)) + return false; + + raw = raw?.Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return false; + + value = raw; + return true; + } + + private static async Task BuildAsync(HttpContext ctx, TransportCredentialKind kind, string value) + => new() + { + Kind = kind, + Value = value, + TenantId = ctx.GetTenant().Value, + Device = await ctx.GetDeviceAsync() + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs new file mode 100644 index 00000000..9af510d5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/CredentialResponseWriter.cs @@ -0,0 +1,89 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class CredentialResponseWriter : ICredentialResponseWriter +{ + private readonly IAuthFlowContextAccessor _authContext; + private readonly IUAuthCookieManager _cookieManager; + private readonly IUAuthCookiePolicyBuilder _cookiePolicy; + private readonly IUAuthHeaderPolicyBuilder _headerPolicy; + + public CredentialResponseWriter( + IAuthFlowContextAccessor authContext, + IUAuthCookieManager cookieManager, + IUAuthCookiePolicyBuilder cookiePolicy, + IUAuthHeaderPolicyBuilder headerPolicy) + { + _authContext = authContext; + _cookieManager = cookieManager; + _cookiePolicy = cookiePolicy; + _headerPolicy = headerPolicy; + } + + public void Write(HttpContext context, GrantKind kind, AuthSessionId sessionId) + => WriteInternal(context, kind, sessionId.ToString()); + + public void Write(HttpContext context, GrantKind kind, AccessToken token) + => WriteInternal(context, kind, token.Token); + + public void Write(HttpContext context, GrantKind kind, RefreshTokenInfo token) + => WriteInternal(context, kind, token.Token); + + public void WriteInternal(HttpContext context, GrantKind kind, string value) + { + var auth = _authContext.Current; + var delivery = ResolveDelivery(auth.Response, kind); + + + if (delivery.Mode == TokenResponseMode.None) + return; + + switch (delivery.Mode) + { + case TokenResponseMode.Cookie: + WriteCookie(context, kind, value, delivery, auth); + break; + + case TokenResponseMode.Header: + WriteHeader(context, value, delivery, auth); + break; + + case TokenResponseMode.Body: + // TODO: Implement body writing if needed + break; + } + } + + private void WriteCookie(HttpContext context, GrantKind kind, string value, CredentialResponseOptions options, AuthFlowContext auth) + { + if (options.Cookie is null) + throw new InvalidOperationException($"Cookie options missing for credential '{kind}'."); + + var cookieOptions = _cookiePolicy.Build(options, auth, kind); + _cookieManager.Write(context, options.Cookie.Name, value, cookieOptions); + } + + private void WriteHeader(HttpContext context, string value, CredentialResponseOptions response, AuthFlowContext auth) + { + var headerName = response.Name ?? "Authorization"; + var formatted = _headerPolicy.BuildHeaderValue(value, response, auth); + + context.Response.Headers[headerName] = formatted; + } + + private static CredentialResponseOptions ResolveDelivery(EffectiveAuthResponse response, GrantKind kind) + => kind switch + { + GrantKind.Session => response.SessionIdDelivery, + GrantKind.AccessToken => response.AccessTokenDelivery, + GrantKind.RefreshToken => response.RefreshTokenDelivery, + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthBodyPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthBodyPolicyBuilder.cs new file mode 100644 index 00000000..8ba70381 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthBodyPolicyBuilder.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUAuthBodyPolicyBuilder +{ + object BuildBodyValue(string rawValue, CredentialResponseOptions response, AuthFlowContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthHeaderPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthHeaderPolicyBuilder.cs new file mode 100644 index 00000000..6a21b58c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IUAuthHeaderPolicyBuilder.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUAuthHeaderPolicyBuilder +{ + string BuildHeaderValue(string rawValue, CredentialResponseOptions response, AuthFlowContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs new file mode 100644 index 00000000..3a8efc49 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/IValidateCredentialResolver.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// Gets the credential from the HTTP context. +/// IPrimaryCredentialResolver is used to determine which kind of credential to resolve. +/// +public interface IValidateCredentialResolver +{ + Task ResolveAsync(HttpContext context, EffectiveAuthResponse response); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs new file mode 100644 index 00000000..3ba31678 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/PrimaryCredentialResolver.cs @@ -0,0 +1,37 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// TODO: Enhance class (endpoint-based, tenant-based, route-based) +internal sealed class PrimaryCredentialResolver : IPrimaryCredentialResolver +{ + private readonly UAuthServerOptions _options; + + public PrimaryCredentialResolver(IOptions options) + { + _options = options.Value; + } + + public PrimaryGrantKind Resolve(HttpContext context) + { + if (IsApiRequest(context)) + return _options.PrimaryCredential.Api; + + return _options.PrimaryCredential.Ui; + } + + private static bool IsApiRequest(HttpContext context) + { + if (context.Request.Path.StartsWithSegments("/api")) + return true; + + if (context.Request.Headers.ContainsKey("Authorization")) + return true; + + return false; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthBodyPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthBodyPolicyBuilder.cs new file mode 100644 index 00000000..8aac2412 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthBodyPolicyBuilder.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal class UAuthBodyPolicyBuilder : IUAuthBodyPolicyBuilder +{ + public object BuildBodyValue(string rawValue, CredentialResponseOptions response, AuthFlowContext context) + { + throw new NotImplementedException(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthHeaderPolicyBuilder.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthHeaderPolicyBuilder.cs new file mode 100644 index 00000000..49789b5e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/UAuthHeaderPolicyBuilder.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class UAuthHeaderPolicyBuilder : IUAuthHeaderPolicyBuilder +{ + public string BuildHeaderValue(string rawValue, CredentialResponseOptions response, AuthFlowContext context) + { + if (string.IsNullOrWhiteSpace(rawValue)) + throw new ArgumentException("Header value cannot be empty.", nameof(rawValue)); + + return response.HeaderFormat switch + { + HeaderTokenFormat.Bearer => $"Bearer {rawValue}", + HeaderTokenFormat.Raw => rawValue, + _ => throw new InvalidOperationException($"Unsupported header token format: {response.HeaderFormat}") + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs new file mode 100644 index 00000000..1923e5c3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Credentials/ValidateCredentialResolver.cs @@ -0,0 +1,90 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class ValidateCredentialResolver : IValidateCredentialResolver +{ + private readonly IPrimaryCredentialResolver _primaryResolver; + + public ValidateCredentialResolver(IPrimaryCredentialResolver primaryResolver) + { + _primaryResolver = primaryResolver; + } + + public async Task ResolveAsync(HttpContext context, EffectiveAuthResponse response) + { + var kind = _primaryResolver.Resolve(context); + + return kind switch + { + PrimaryGrantKind.Stateful => await ResolveSession(context, response), + PrimaryGrantKind.Stateless => await ResolveAccessToken(context, response), + + _ => null + }; + } + + private static async Task ResolveSession(HttpContext context, EffectiveAuthResponse response) + { + var delivery = response.SessionIdDelivery; + + if (delivery.Mode != TokenResponseMode.Cookie) + return null; + + var cookie = delivery.Cookie; + if (cookie is null) + return null; + + if (!context.Request.Cookies.TryGetValue(cookie.Name, out var raw)) + return null; + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + return new ResolvedCredential + { + Kind = PrimaryGrantKind.Stateful, + Value = raw.Trim(), + Tenant = context.GetTenant(), + Device = await context.GetDeviceAsync() + }; + } + + private static async Task ResolveAccessToken(HttpContext context, EffectiveAuthResponse response) + { + var delivery = response.AccessTokenDelivery; + + if (delivery.Mode != TokenResponseMode.Header) + return null; + + var headerName = delivery.Name ?? "Authorization"; + + if (!context.Request.Headers.TryGetValue(headerName, out var header)) + return null; + + var value = header.ToString(); + + if (delivery.HeaderFormat == HeaderTokenFormat.Bearer && + value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + value = value["Bearer ".Length..].Trim(); + } + + if (string.IsNullOrWhiteSpace(value)) + return null; + + return new ResolvedCredential + { + Kind = PrimaryGrantKind.Stateless, + Value = value, + Tenant = context.GetTenant(), + Device = await context.GetDeviceAsync() + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs new file mode 100644 index 00000000..dde39b84 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/DevelopmentJwtSigningKeyProvider.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Contracts; +using Microsoft.IdentityModel.Tokens; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class DevelopmentJwtSigningKeyProvider : IJwtSigningKeyProvider +{ + private readonly JwtSigningKey _key; + + public DevelopmentJwtSigningKeyProvider() + { + var rawKey = Encoding.UTF8.GetBytes("DEV_ONLY__ULTIMATEAUTH__DO_NOT_USE_IN_PROD"); + + _key = new JwtSigningKey + { + KeyId = "dev-uauth", + Algorithm = SecurityAlgorithms.HmacSha256, + Key = new SymmetricSecurityKey(rawKey) + { + KeyId = "dev-uauth" + } + }; + } + + public JwtSigningKey Resolve(string? keyId) + { + return _key; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs new file mode 100644 index 00000000..e1e7dccd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceContextFactory.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class DeviceContextFactory : IDeviceContextFactory +{ + public DeviceContext Create(DeviceInfo device) + { + if (device is null || string.IsNullOrWhiteSpace(device.DeviceId.Value)) + return DeviceContext.Anonymous(); + + return DeviceContext.Create( + deviceId: device.DeviceId, + deviceType: device.DeviceType, + platform: device.Platform, + operatingSystem: device.OperatingSystem, + browser: device.Browser, + ipAddress: device.IpAddress + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs new file mode 100644 index 00000000..8fa959b7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/DeviceResolver.cs @@ -0,0 +1,81 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// TODO: Consider creating a seperate package with a library like UA Parser, WURFL or DeviceAtlas for more accurate device detection. +public sealed class DeviceResolver : IDeviceResolver +{ + private readonly IUserAgentParser _userAgentParser; + + public DeviceResolver(IUserAgentParser userAgentParser) + { + _userAgentParser = userAgentParser; + } + + public async Task ResolveAsync(HttpContext context) + { + var request = context.Request; + + var rawDeviceId = await ResolveRawDeviceId(context); + if (!DeviceId.TryCreate(rawDeviceId, out var deviceId)) + { + //throw new InvalidOperationException("device_id_required"); + } + + var ua = request.Headers.UserAgent.ToString(); + var parsed = _userAgentParser.Parse(ua); + + var deviceInfo = new DeviceInfo + { + DeviceId = deviceId, + DeviceType = parsed.DeviceType, + Platform = parsed.Platform, + OperatingSystem = parsed.OperatingSystem, + Browser = parsed.Browser, + UserAgent = ua, + IpAddress = ResolveIp(context) + }; + + return deviceInfo; + } + + private static async Task ResolveRawDeviceId(HttpContext context) + { + if (context.Request.Headers.TryGetValue("X-UDID", out var header)) + return header.ToString(); + + if (context.Request.HasFormContentType) + { + var form = await context.GetCachedFormAsync(); + + if (form is not null && + form.TryGetValue(UAuthConstants.Form.Device, out var formValue) && + !StringValues.IsNullOrEmpty(formValue)) + { + return formValue.ToString(); + } + } + + if (context.Request.Cookies.TryGetValue("udid", out var cookie)) + return cookie; + + return null; + } + + private static string? ResolveIp(HttpContext context) + { + var forwarded = context.Request.Headers["X-Forwarded-For"].FirstOrDefault(); + + if (!string.IsNullOrWhiteSpace(forwarded)) + return forwarded.Split(',')[0].Trim(); + + return context.Connection.RemoteIpAddress?.ToString(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs new file mode 100644 index 00000000..b26db0de --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Device/IDeviceContextFactory.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IDeviceContextFactory +{ + DeviceContext Create(DeviceInfo requestDevice); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs new file mode 100644 index 00000000..a9ab6d46 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/JwtTokenGenerator.cs @@ -0,0 +1,66 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Abstractions; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class JwtTokenGenerator : IJwtTokenGenerator +{ + private readonly IJwtSigningKeyProvider _keyProvider; + private readonly JsonWebTokenHandler _handler = new(); + + public JwtTokenGenerator(IJwtSigningKeyProvider keyProvider) + { + _keyProvider = keyProvider; + } + + public string CreateToken(UAuthJwtTokenDescriptor descriptor) + { + var signingKey = _keyProvider.Resolve(descriptor.KeyId); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Issuer = descriptor.Issuer, + Audience = descriptor.Audience, + Subject = null, + NotBefore = descriptor.IssuedAt.UtcDateTime, + IssuedAt = descriptor.IssuedAt.UtcDateTime, + Expires = descriptor.ExpiresAt.UtcDateTime, + + Claims = BuildClaims(descriptor), + + 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; + + if (descriptor.Claims is not null) + { + foreach (var kv in descriptor.Claims) + { + claims[kv.Key] = kv.Value; + } + } + + return claims; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs new file mode 100644 index 00000000..52547057 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/NumericCodeGenerator.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class NumericCodeGenerator : INumericCodeGenerator +{ + public string Generate(int digits = 6) + { + var max = (int)Math.Pow(10, digits); + var number = RandomNumberGenerator.GetInt32(max); + + return number.ToString($"D{digits}"); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs new file mode 100644 index 00000000..9dee1407 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Generators/OpaqueTokenGenerator.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class OpaqueTokenGenerator : IOpaqueTokenGenerator +{ + private readonly UAuthTokenOptions _options; + + public OpaqueTokenGenerator(IOptions options) + { + _options = options.Value.Token; + } + + public string Generate() => GenerateBytes(_options.OpaqueIdBytes); + public string GenerateJwtId() => GenerateBytes(16); + private static string GenerateBytes(int bytes) => WebEncoders.Base64UrlEncode(RandomNumberGenerator.GetBytes(bytes)); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs new file mode 100644 index 00000000..941c23d1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HmacSha256TokenHasher.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class HmacSha256TokenHasher : ITokenHasher +{ + private readonly byte[] _key; + + public HmacSha256TokenHasher(byte[] key) + { + if (key is null || key.Length == 0) + throw new ArgumentException("Token hashing key must be provided.", nameof(key)); + + _key = key; + } + + public string Hash(string plaintext) + { + using var hmac = new HMACSHA256(_key); + var bytes = Encoding.UTF8.GetBytes(plaintext); + var hash = hmac.ComputeHash(bytes); + return Convert.ToBase64String(hash); + } + + public bool Verify(string hash, string plaintext) + { + var computed = Hash(plaintext); + + return CryptographicOperations.FixedTimeEquals( + Convert.FromBase64String(hash), + Convert.FromBase64String(computed)); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs new file mode 100644 index 00000000..9af186da --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HandleHubEntry.cs @@ -0,0 +1,78 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Options; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal class HandleHub +{ + internal static async Task HandleHubEntry(HttpContext ctx, IAuthStore store, IClock clock, IOptions options) + { + var form = await ctx.GetCachedFormAsync(); + + if (form is null) + return Results.BadRequest("Form content required."); + + var authorizationCode = form["authorization_code"].ToString(); + var codeVerifier = form["code_verifier"].ToString(); + var deviceId = form["device_id"].ToString(); + var returnUrl = form["return_url"].ToString(); + + if (!Enum.TryParse(form["__uauth_client_profile"], ignoreCase: true, out var clientProfile)) + { + clientProfile = UAuthClientProfile.NotSpecified; + } + + var hubSessionId = HubSessionId.New(); + + var payload = new HubFlowPayload(); + payload.Set("authorization_code", authorizationCode); + payload.Set("code_verifier", codeVerifier); + + var tenant = ctx.GetTenant(); + + var deviceRaw = form["device"].FirstOrDefault(); + DeviceContext device; + + if (!string.IsNullOrWhiteSpace(deviceRaw)) + { + try + { + var bytes = WebEncoders.Base64UrlDecode(deviceRaw); + var json = Encoding.UTF8.GetString(bytes); + + device = JsonSerializer.Deserialize(json) ?? DeviceContext.Anonymous(); + } + catch + { + device = DeviceContext.Anonymous(); + } + } + else + { + device = DeviceContext.Anonymous(); + } + + var artifact = new HubFlowArtifact( + hubSessionId, + HubFlowType.Login, + clientProfile, + tenant, + device, + returnUrl, + payload, + clock.UtcNow.Add(options.Value.Hub.FlowLifetime)); + + await store.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact); + return Results.Redirect($"{options.Value.Hub.LoginPath}?{UAuthConstants.Query.Hub}={hubSessionId.Value}"); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs new file mode 100644 index 00000000..9dbf2d22 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubCredentialResolver.cs @@ -0,0 +1,39 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class HubCredentialResolver : IHubCredentialResolver +{ + private readonly IAuthStore _store; + + public HubCredentialResolver(IAuthStore store) + { + _store = store; + } + + public async Task ResolveAsync(HubSessionId hubSessionId, CancellationToken ct = default) + { + var artifact = await _store.GetAsync(new AuthArtifactKey(hubSessionId.Value), ct); + + if (artifact is not HubFlowArtifact flow) + return null; + + if (flow.IsCompleted) + return null; + + if (!flow.Payload.TryGet("authorization_code", out string? authorizationCode) || string.IsNullOrWhiteSpace(authorizationCode)) + return null; + + if (!flow.Payload.TryGet("code_verifier", out string? codeVerifier) || string.IsNullOrWhiteSpace(codeVerifier)) + return null; + + return new HubCredentials + { + AuthorizationCode = authorizationCode, + CodeVerifier = codeVerifier, + ClientProfile = flow.ClientProfile, + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs new file mode 100644 index 00000000..55c96fa4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/HubFlowReader.cs @@ -0,0 +1,41 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Stores; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class HubFlowReader : IHubFlowReader +{ + private readonly IAuthStore _store; + private readonly IClock _clock; + + public HubFlowReader(IAuthStore store, IClock clock) + { + _store = store; + _clock = clock; + } + + public async Task GetStateAsync(HubSessionId hubSessionId, CancellationToken ct = default) + { + var artifact = await _store.GetAsync(new AuthArtifactKey(hubSessionId.Value), ct); + + if (artifact is not HubFlowArtifact flow) + return null; + + var now = _clock.UtcNow; + + return new HubFlowState + { + Exists = true, + HubSessionId = flow.HubSessionId, + FlowType = flow.FlowType, + ClientProfile = flow.ClientProfile, + ReturnUrl = flow.ReturnUrl, + Error = flow.Error, + AttemptCount = flow.AttemptCount, + IsExpired = flow.IsExpired(now), + IsCompleted = flow.IsCompleted, + IsActive = !flow.IsExpired(now) && !flow.IsCompleted + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs new file mode 100644 index 00000000..6944fc73 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Hub/UAuthHubMarker.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public class UAuthHubMarker : IUAuthHubMarker +{ + public bool RequiresCors => true; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs new file mode 100644 index 00000000..e46d2bda --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/HubCapabilities.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class HubCapabilities : IHubCapabilities +{ + public bool SupportsPkce => true; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs new file mode 100644 index 00000000..38cf330d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthSessionIssuer.cs @@ -0,0 +1,296 @@ +๏ปฟ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.Options; +using Microsoft.Extensions.Options; +using System.Security; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthSessionIssuer : ISessionIssuer +{ + private readonly ISessionStoreFactory _storeFactory; + private readonly IOpaqueTokenGenerator _opaqueGenerator; + private readonly UAuthServerOptions _options; + + public UAuthSessionIssuer(ISessionStoreFactory storeFactory, IOpaqueTokenGenerator opaqueGenerator, IOptions options) + { + _storeFactory = storeFactory; + _opaqueGenerator = opaqueGenerator; + _options = options.Value; + } + + public async Task IssueSessionAsync(SessionIssuanceContext context, CancellationToken ct = default) + { + // Defensive guard โ€” enforcement belongs to Authority + if (context.Mode == UAuthMode.PureJwt) + { + throw new InvalidOperationException("Session issuance is not allowed in PureJwt mode."); + } + + var now = context.Now; + var opaqueSessionId = _opaqueGenerator.Generate(); + if (!AuthSessionId.TryCreate(opaqueSessionId, out AuthSessionId sessionId)) + throw new InvalidCastException("Can't create opaque id."); + + var expiresAt = now.Add(_options.Session.Lifetime); + + if (_options.Session.MaxLifetime is not null) + { + var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); + if (absoluteExpiry < expiresAt) + expiresAt = absoluteExpiry; + } + + var kernel = _storeFactory.Create(context.Tenant); + + IssuedSession? issued = null; + + await kernel.ExecuteAsync(async _ => + { + var root = await kernel.GetRootByUserAsync(context.UserKey); + + if (root is null) + { + root = UAuthSessionRoot.Create(context.Tenant, context.UserKey, now); + await kernel.CreateRootAsync(root); + } + else if (root.IsRevoked) + { + throw new UAuthValidationException("Session root revoked."); + } + + UAuthSessionChain chain; + + if (context.ChainId is not null) + { + var existing = await kernel.GetChainAsync(context.ChainId.Value); + + if (existing is null) + { + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + context.Tenant, + context.UserKey, + now, + expiresAt, + context.Device, + ClaimsSnapshot.Empty, + root.SecurityVersion + ); + await kernel.CreateChainAsync(chain); + } + else + { + chain = existing; + } + + //chain = await kernel.GetChainAsync(context.ChainId.Value) + // ?? throw new UAuthNotFoundException("Chain not found."); + + if (chain.IsRevoked) + throw new UAuthValidationException("Chain revoked."); + + if (chain.UserKey != context.UserKey || chain.Tenant != context.Tenant) + throw new UAuthValidationException("Invalid chain ownership."); + } + else + { + chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + context.Tenant, + context.UserKey, + now, + expiresAt, + context.Device, + ClaimsSnapshot.Empty, + root.SecurityVersion + ); + + await kernel.CreateChainAsync(chain); + } + + var sessions = await kernel.GetSessionsByChainAsync(chain.ChainId); + + if (sessions.Count >= _options.Session.MaxSessionsPerChain) + { + var toDelete = sessions + .Where(s => s.SessionId != chain.ActiveSessionId) + .OrderBy(x => x.CreatedAt) + .Take(sessions.Count - _options.Session.MaxSessionsPerChain + 1) + .ToList(); + + foreach (var old in toDelete) + { + await kernel.RemoveSessionAsync(old.SessionId); + } + } + + var session = UAuthSession.Create( + sessionId: sessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: chain.ChainId, + now: now, + expiresAt: expiresAt, + securityVersion: root.SecurityVersion, + device: context.Device, + claims: context.Claims, + metadata: context.Metadata + ); + + await kernel.CreateSessionAsync(session); + + var updatedChain = chain.AttachSession(session.SessionId, now); + await kernel.SaveChainAsync(updatedChain, chain.Version); + + issued = new IssuedSession + { + Session = session, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid + }; + }, ct); + + if (issued == null) + throw new InvalidCastException("Issue failed."); + return issued; + } + + public async Task RotateSessionAsync(SessionRotationContext context, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(context.Tenant); + var now = context.Now; + + var opaqueSessionId = _opaqueGenerator.Generate(); + if (!AuthSessionId.TryCreate(opaqueSessionId, out var newSessionId)) + throw new InvalidCastException("Can't create opaque session id."); + + var expiresAt = now.Add(_options.Session.Lifetime); + if (_options.Session.MaxLifetime is not null) + { + var absoluteExpiry = now.Add(_options.Session.MaxLifetime.Value); + if (absoluteExpiry < expiresAt) + expiresAt = absoluteExpiry; + } + + IssuedSession? issued = null; + + await kernel.ExecuteAsync(async _ => + { + var root = await kernel.GetRootByUserAsync(context.UserKey); + if (root == null) + throw new SecurityException("Session root not found"); + + if (root.IsRevoked) + throw new SecurityException("Session root is revoked"); + + var oldSession = await kernel.GetSessionAsync(context.CurrentSessionId) + ?? throw new SecurityException("Session not found"); + + if (oldSession.IsRevoked || oldSession.ExpiresAt <= now) + throw new SecurityException("Session is not valid"); + + if (oldSession.SecurityVersionAtCreation != root.SecurityVersion) + throw new SecurityException("Security version mismatch"); + + var chain = await kernel.GetChainAsync(oldSession.ChainId) + ?? throw new SecurityException("Chain not found"); + + if (chain.IsRevoked) + throw new SecurityException("Chain is revoked"); + + if (chain.Tenant != context.Tenant || chain.UserKey != context.UserKey) + throw new SecurityException("Chain does not belong to the current user/tenant."); + + var newSessionUnbound = UAuthSession.Create( + sessionId: newSessionId, + tenant: context.Tenant, + userKey: context.UserKey, + chainId: SessionChainId.Unassigned, + now: now, + expiresAt: expiresAt, + securityVersion: root.SecurityVersion, + device: context.Device, + claims: context.Claims, + metadata: context.Metadata + ); + + issued = new IssuedSession + { + Session = newSessionUnbound, + OpaqueSessionId = opaqueSessionId, + IsMetadataOnly = context.Mode == UAuthMode.SemiHybrid + }; + + var newSession = issued.Session.WithChain(chain.ChainId); + + await kernel.CreateSessionAsync(newSession); + var chainExpected = chain.Version; + var updatedChain = chain.RotateSession(newSession.SessionId, now, context.Claims); + await kernel.SaveChainAsync(updatedChain, chainExpected); + + var expected = oldSession.Version; + var revokedOld = oldSession.Revoke(now); + await kernel.SaveSessionAsync(revokedOld, expected); + }, ct); + + if (issued == null) + throw new InvalidCastException("Can't issued session."); + return issued; + } + + public async Task RevokeSessionAsync(TenantKey tenant, AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenant); + return await kernel.ExecuteAsync(_ => kernel.RevokeSessionAsync(sessionId, at), ct); + } + + public async Task RevokeChainAsync(TenantKey tenant, SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenant); + await kernel.ExecuteAsync(async _ => + { + var chain = await kernel.GetChainAsync(chainId); + if (chain is null) + return; + + await kernel.RevokeChainCascadeAsync(chainId, at); + }, ct); + } + + public async Task RevokeAllChainsAsync(TenantKey tenant, UserKey userKey, SessionChainId? exceptChainId, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenant); + await kernel.ExecuteAsync(async _ => + { + var chains = await kernel.GetChainsByUserAsync(userKey); + + foreach (var chain in chains) + { + if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) + continue; + + await kernel.RevokeChainCascadeAsync(chain.ChainId, at); + } + }, ct); + } + + public async Task RevokeRootAsync(TenantKey tenant, UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(tenant); + await kernel.ExecuteAsync(async _ => + { + var root = await kernel.GetRootByUserAsync(userKey); + if (root is null) + return; + + await kernel.RevokeRootCascadeAsync(userKey, at); + }, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs new file mode 100644 index 00000000..cb0b5955 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Issuers/UAuthTokenIssuer.cs @@ -0,0 +1,143 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Abstactions; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// Default UltimateAuth token issuer. +/// Opinionated implementation of ITokenIssuer. +/// Mode-aware (PureOpaque, Hybrid, SemiHybrid, PureJwt). +/// +public sealed class UAuthTokenIssuer : ITokenIssuer +{ + private readonly IOpaqueTokenGenerator _opaqueGenerator; + private readonly IJwtTokenGenerator _jwtGenerator; + private readonly ITokenHasher _tokenHasher; + private readonly IRefreshTokenStoreFactory _storeFactory; + private readonly IClock _clock; + + public UAuthTokenIssuer(IOpaqueTokenGenerator opaqueGenerator, IJwtTokenGenerator jwtGenerator, ITokenHasher tokenHasher, IRefreshTokenStoreFactory storeFactory, IClock clock) + { + _opaqueGenerator = opaqueGenerator; + _jwtGenerator = jwtGenerator; + _tokenHasher = tokenHasher; + _storeFactory = storeFactory; + _clock = clock; + } + + public Task IssueAccessTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, CancellationToken ct = default) + { + var tokens = flow.OriginalOptions.Token; + var now = _clock.UtcNow; + var expires = now.Add(tokens.AccessTokenLifetime); + + return flow.EffectiveMode switch + { + // TODO: Discuss, Hybrid token may be JWT. + UAuthMode.PureOpaque or UAuthMode.Hybrid => + Task.FromResult(IssueOpaqueAccessToken(expires, flow?.Session?.SessionId.ToString())), + + UAuthMode.SemiHybrid or + UAuthMode.PureJwt => + Task.FromResult(IssueJwtAccessToken(context, tokens, expires)), + + _ => throw new InvalidOperationException($"Unsupported auth mode: {flow.EffectiveMode}") + }; + } + + public async Task IssueRefreshTokenAsync(AuthFlowContext flow, TokenIssuanceContext context, RefreshTokenPersistence persistence, CancellationToken ct = default) + { + if (flow.EffectiveMode == UAuthMode.PureOpaque) + return null; + + if (context.SessionId is not AuthSessionId sessionId) + return null; + + var now = _clock.UtcNow; + var expires = now.Add(flow.OriginalOptions.Token.RefreshTokenLifetime); + + var raw = _opaqueGenerator.Generate(); + var hash = _tokenHasher.Hash(raw); + + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: hash, + tenant: flow.Tenant, + userKey: context.UserKey, + sessionId: sessionId, + chainId: context.ChainId, + createdAt: now, + expiresAt: expires); + + if (persistence == RefreshTokenPersistence.Persist) + { + var store = _storeFactory.Create(flow.Tenant); + await store.StoreAsync(stored, ct); + } + + return new RefreshTokenInfo + { + Token = raw, + TokenHash = hash, + ExpiresAt = expires + }; + } + + private AccessToken IssueOpaqueAccessToken(DateTimeOffset expires, string? sessionId) + { + string token = _opaqueGenerator.Generate(); + + return new AccessToken + { + Token = token, + Type = TokenType.Opaque, + ExpiresAt = expires, + SessionId = sessionId + }; + } + + private AccessToken IssueJwtAccessToken(TokenIssuanceContext context, UAuthTokenOptions tokens, DateTimeOffset expires) + { + var claims = new Dictionary + { + ["sub"] = context.UserKey.Value, + ["tenant"] = context.Tenant + }; + + foreach (var kv in context.Claims) + claims[kv.Key] = kv.Value; + + if (context.SessionId != null) + claims["sid"] = context.SessionId!; + + if (tokens.AddJwtIdClaim) + claims["jti"] = _opaqueGenerator.GenerateJwtId(); + + var descriptor = new UAuthJwtTokenDescriptor + { + Subject = context.UserKey, + Issuer = tokens.Issuer, + Audience = tokens.Audience, + IssuedAt = _clock.UtcNow, + ExpiresAt = expires, + Tenant = context.Tenant, + Claims = claims, + KeyId = tokens.KeyId + }; + + var jwt = _jwtGenerator.CreateToken(descriptor); + + return new AccessToken + { + Token = jwt, + Type = TokenType.Jwt, + ExpiresAt = expires, + SessionId = context.SessionId.ToString() + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs new file mode 100644 index 00000000..80f15028 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IIdentifierNormalizer.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IIdentifierNormalizer +{ + NormalizedIdentifier Normalize(UserIdentifierType type, string value); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs new file mode 100644 index 00000000..0fd00be7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/IdentifierNormalizer.cs @@ -0,0 +1,132 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Globalization; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class IdentifierNormalizer : IIdentifierNormalizer +{ + private readonly UAuthIdentifierNormalizationOptions _options; + + public IdentifierNormalizer(IOptions options) + { + _options = options.Value.LoginIdentifiers.Normalization; + } + + public NormalizedIdentifier Normalize(UserIdentifierType type, string value) + { + if (string.IsNullOrWhiteSpace(value)) + return new(value, string.Empty, false, "identifier_empty"); + + var raw = value; + var normalized = BasicNormalize(value); + + return type switch + { + UserIdentifierType.Email => NormalizeEmail(raw, normalized), + UserIdentifierType.Phone => NormalizePhone(raw, normalized), + UserIdentifierType.Username => NormalizeUsername(raw, normalized), + _ => NormalizeCustom(raw, normalized) + }; + } + + private static string BasicNormalize(string value) + { + var form = value.Normalize(NormalizationForm.FormKC).Trim(); + + var sb = new StringBuilder(form.Length); + foreach (var ch in form) + { + if (char.IsControl(ch)) + continue; + + if (ch is '\u200B' or '\u200C' or '\u200D' or '\uFEFF') + continue; + + sb.Append(ch); + } + + return sb.ToString(); + } + + private NormalizedIdentifier NormalizeUsername(string raw, string value) + { + if (value.Length < 3 || value.Length > 256) + return new(raw, value, false, "username_invalid_length"); + + value = ApplyCasePolicy(value, _options.UsernameCase); + + return new(raw, value, true, null); + } + + private NormalizedIdentifier NormalizeEmail(string raw, string value) + { + var atIndex = value.IndexOf('@'); + if (atIndex <= 0 || atIndex != value.LastIndexOf('@')) + return new(raw, value, false, "email_invalid_format"); + + var local = value[..atIndex]; + var domain = value[(atIndex + 1)..]; + + if (string.IsNullOrWhiteSpace(domain) || !domain.Contains('.')) + return new(raw, value, false, "email_invalid_domain"); + + try + { + var idn = new IdnMapping(); + domain = idn.GetAscii(domain); + } + catch + { + return new(raw, value, false, "email_invalid_domain"); + } + + var normalized = $"{local}@{domain}"; + normalized = ApplyCasePolicy(normalized, _options.EmailCase); + + return new(raw, normalized, true, null); + } + + private NormalizedIdentifier NormalizePhone(string raw, string value) + { + var sb = new StringBuilder(); + + foreach (var ch in value) + { + if (char.IsDigit(ch)) + sb.Append(ch); + else if (ch == '+' && sb.Length == 0) + sb.Append(ch); + } + + var digits = sb.ToString(); + + if (digits.Length < 7) + return new(raw, digits, false, "phone_invalid_length"); + + return new(raw, digits, true, null); + } + + private NormalizedIdentifier NormalizeCustom(string raw, string value) + { + value = ApplyCasePolicy(value, _options.CustomCase); + + if (value.Length == 0) + return new(raw, value, false, "identifier_invalid"); + + return new(raw, value, true, null); + } + + private static string ApplyCasePolicy(string value, CaseHandling policy) + { + return policy switch + { + CaseHandling.ToLower => value.ToLowerInvariant(), + CaseHandling.ToUpper => value.ToUpperInvariant(), + _ => value + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs new file mode 100644 index 00000000..9bf3d7b8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Normalizer/NormalizedIdentifier.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public readonly record struct NormalizedIdentifier( + string Raw, + string Normalized, + bool IsValid, + string? ErrorCode); diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs new file mode 100644 index 00000000..69588e26 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/AccessCommand.cs @@ -0,0 +1,25 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class AccessCommand : IAccessCommand +{ + private readonly Func _execute; + + public AccessCommand(Func execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} + +public sealed class AccessCommand : IAccessCommand +{ + private readonly Func> _execute; + + public AccessCommand(Func> execute) + { + _execute = execute; + } + + public Task ExecuteAsync(CancellationToken ct = default) => _execute(ct); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs new file mode 100644 index 00000000..b756516f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/CreateLoginSessionCommand.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record CreateLoginSessionCommand(SessionIssuanceContext LoginContext) : ISessionCommand +{ + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + { + return issuer.IssueSessionAsync(LoginContext, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs new file mode 100644 index 00000000..9a2c61c5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessCommand.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IAccessCommand +{ + Task ExecuteAsync(CancellationToken ct = default); +} + +// For get commands +public interface IAccessCommand +{ + Task ExecuteAsync(CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs new file mode 100644 index 00000000..9ad05368 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/IAccessOrchestrator.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IAccessOrchestrator +{ + Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default); + Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs new file mode 100644 index 00000000..e60da79a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionCommand.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface ISessionCommand +{ + Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs new file mode 100644 index 00000000..c1e75e5e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/ISessionOrchestrator.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal interface ISessionOrchestrator +{ + Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs new file mode 100644 index 00000000..51e814cd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeAllChainsCommand.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RevokeAllChainsCommand : ISessionCommand +{ + public UserKey UserKey { get; } + public SessionChainId? ExceptChainId { get; } + + public RevokeAllChainsCommand(UserKey userKey, SessionChainId? exceptChainId) + { + UserKey = userKey; + ExceptChainId = exceptChainId; + } + + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + await issuer.RevokeAllChainsAsync(context.Tenant, UserKey, ExceptChainId, context.At, ct); + return Unit.Value; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs new file mode 100644 index 00000000..2432db8a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeChainCommand.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RevokeChainCommand : ISessionCommand +{ + public SessionChainId ChainId { get; } + + public RevokeChainCommand(SessionChainId chainId) + { + ChainId = chainId; + } + + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + await issuer.RevokeChainAsync(context.Tenant, ChainId, context.At, ct); + return Unit.Value; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs new file mode 100644 index 00000000..ab3b2ce1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeRootCommand.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RevokeRootCommand : ISessionCommand +{ + public UserKey UserKey { get; } + + public RevokeRootCommand(UserKey userKey) + { + UserKey = userKey; + } + + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + await issuer.RevokeRootAsync(context.Tenant, UserKey, context.At, ct); + return Unit.Value; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs new file mode 100644 index 00000000..a2aa8f20 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RevokeSessionCommand.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record RevokeSessionCommand(AuthSessionId SessionId) : ISessionCommand +{ + public async Task ExecuteAsync(AuthContext context, ISessionIssuer issuer, CancellationToken ct) + { + return await issuer.RevokeSessionAsync(context.Tenant, SessionId, context.At, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs new file mode 100644 index 00000000..70fc768f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/RotateSessionCommand.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed record RotateSessionCommand(SessionRotationContext RotationContext) : ISessionCommand +{ + public Task ExecuteAsync(AuthContext _, ISessionIssuer issuer, CancellationToken ct) + { + return issuer.RotateSessionAsync(RotationContext, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs new file mode 100644 index 00000000..01d91819 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessAuthority.cs @@ -0,0 +1,59 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthAccessAuthority : IAccessAuthority +{ + private readonly IEnumerable _invariants; + private readonly IEnumerable _globalPolicies; + + public UAuthAccessAuthority(IEnumerable invariants, IEnumerable globalPolicies) + { + _invariants = invariants ?? Array.Empty(); + _globalPolicies = globalPolicies ?? Array.Empty(); + } + + public AccessDecision Decide(AccessContext context, IEnumerable runtimePolicies) + { + foreach (var invariant in _invariants) + { + var result = invariant.Decide(context); + if (!result.IsAllowed) + return result; + } + + foreach (var policy in _globalPolicies) + { + if (!policy.AppliesTo(context)) + continue; + + var result = policy.Decide(context); + + if (!result.IsAllowed) + return result; + + // Allow here means "no objection", NOT permission + } + + bool requiresReauth = false; + + foreach (var policy in runtimePolicies) + { + if (!policy.AppliesTo(context)) + continue; + + var result = policy.Decide(context); + + if (!result.IsAllowed) + return result; + + if (result.RequiresReauthentication) + requiresReauth = true; + } + + return requiresReauth + ? AccessDecision.ReauthenticationRequired() + : AccessDecision.Allow(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs new file mode 100644 index 00000000..6fe26b2d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthAccessOrchestrator.cs @@ -0,0 +1,69 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Policies.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthAccessOrchestrator : IAccessOrchestrator +{ + private readonly IAccessAuthority _authority; + private readonly IAccessPolicyProvider _policyProvider; + private readonly IUserPermissionStore _permissions; + + public UAuthAccessOrchestrator(IAccessAuthority authority, IAccessPolicyProvider policyProvider, IUserPermissionStore permissions) + { + _authority = authority; + _policyProvider = policyProvider; + _permissions = permissions; + } + + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + context = await EnrichAsync(context, ct); + + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); + + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); + + await command.ExecuteAsync(ct); + } + + public async Task ExecuteAsync(AccessContext context, IAccessCommand command, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + context = await EnrichAsync(context, ct); + + var policies = _policyProvider.GetPolicies(context); + var decision = _authority.Decide(context, policies); + + if (!decision.IsAllowed) + throw new UAuthAuthorizationException(decision.DenyReason ?? "authorization_denied"); + + if (decision.RequiresReauthentication) + throw new InvalidOperationException("Requires reauthentication."); + + return await command.ExecuteAsync(ct); + } + + private async Task EnrichAsync(AccessContext context, CancellationToken ct) + { + if (context.ActorUserKey is null) + return context; + + var perms = await _permissions.GetPermissionsAsync(context.ResourceTenant, context.ActorUserKey.Value, ct); + var compiled = new CompiledPermissionSet(perms); + return context.WithAttribute(UAuthConstants.Access.Permissions, compiled); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs new file mode 100644 index 00000000..502f7ed6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Orchestrator/UAuthSessionOrchestrator.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthSessionOrchestrator : ISessionOrchestrator +{ + private readonly IAuthAuthority _authority; + private readonly ISessionIssuer _issuer; + private bool _executed; + + public UAuthSessionOrchestrator(IAuthAuthority authority, ISessionIssuer issuer) + { + _authority = authority; + _issuer = issuer; + } + + public async Task ExecuteAsync(AuthContext authContext, ISessionCommand command, CancellationToken ct = default) + { + if (_executed) + throw new InvalidOperationException("Session orchestrator can only be executed once per operation."); + + _executed = true; + + var decision = _authority.Decide(authContext); + + switch (decision.Decision) + { + case AuthorizationDecision.Deny: + throw new UAuthAuthenticationException(decision.Reason ?? "authorization_denied"); + + case AuthorizationDecision.Challenge: + throw new UAuthChallengeRequiredException(decision.Reason); + + case AuthorizationDecision.Allow: + break; + } + + return await command.ExecuteAsync(authContext, _issuer, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs new file mode 100644 index 00000000..3dbf79af --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/OriginHelper.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class OriginHelper +{ + public static string Normalize(string origin) + { + if (string.IsNullOrWhiteSpace(origin)) + return string.Empty; + + return origin.Trim().TrimEnd('/').ToLowerInvariant(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs new file mode 100644 index 00000000..68b2e7a5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/AuthRedirectResolver.cs @@ -0,0 +1,110 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class AuthRedirectResolver : IAuthRedirectResolver +{ + private readonly ClientBaseAddressResolver _baseAddressResolver; + + public AuthRedirectResolver(ClientBaseAddressResolver baseAddressResolver) + { + _baseAddressResolver = baseAddressResolver; + } + + private static readonly JsonSerializerOptions PayloadJsonOptions = new() + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext ctx) + => Resolve(flow, ctx, flow.Response.Redirect.SuccessPath, null); + + public RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext ctx, AuthFailureReason reason, LoginResult? loginResult = null) + => Resolve(flow, ctx, flow.Response.Redirect.FailurePath, reason, loginResult); + + private RedirectDecision Resolve(AuthFlowContext flow, HttpContext ctx, string? fallbackPath, AuthFailureReason? failureReason, LoginResult? loginResult = null) + { + var redirect = flow.Response.Redirect; + + if (!redirect.Enabled) + return RedirectDecision.None(); + + if (failureReason is null && redirect.AllowReturnUrlOverride && flow.ReturnUrlInfo is { } info) + { + if (info.IsAbsolute && (info.AbsoluteUri!.Scheme == Uri.UriSchemeHttp || info.AbsoluteUri!.Scheme == Uri.UriSchemeHttps)) + { + var origin = info.AbsoluteUri!.GetLeftPart(UriPartial.Authority); + ValidateAllowed(origin, flow.OriginalOptions); + return RedirectDecision.To(info.AbsoluteUri.ToString()); + } + + if (!string.IsNullOrWhiteSpace(info.RelativePath)) + { + var baseAddress = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + return RedirectDecision.To(UrlComposer.Combine(baseAddress, info.RelativePath)); + } + } + + if (string.IsNullOrWhiteSpace(fallbackPath)) + return RedirectDecision.None(); + + var baseUrl = _baseAddressResolver.Resolve(ctx, flow.OriginalOptions); + + var query = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(flow.ReturnUrlInfo?.RelativePath)) + query["returnUrl"] = flow.ReturnUrlInfo.RelativePath; + + // Failure payload + if (failureReason is not null) + { + var payload = new AuthFlowPayload + { + V = 1, + Flow = flow.FlowType, + Status = "failed", + Reason = failureReason + }; + + if (flow.FlowType == AuthFlowType.Login && loginResult is not null && flow.OriginalOptions.Login.IncludeFailureDetails) + { + payload = payload with + { + LockoutUntil = loginResult.LockoutUntilUtc?.ToUnixTimeSeconds(), + RemainingAttempts = loginResult.RemainingAttempts + }; + } + + var json = JsonSerializer.Serialize(payload, PayloadJsonOptions); + + var encoded = Base64UrlTextEncoder.Encode(Encoding.UTF8.GetBytes(json)); + + query["uauth"] = encoded; + } + + return RedirectDecision.To(UrlComposer.Combine(baseUrl, fallbackPath, query)); + } + + private static void ValidateAllowed(string baseAddress, UAuthServerOptions options) + { + if (options.Hub.AllowedClientOrigins.Count == 0) + return; + + var normalized = OriginHelper.Normalize(baseAddress); + + if (!options.Hub.AllowedClientOrigins.Any(o => OriginHelper.Normalize(o) == normalized)) + { + throw new InvalidOperationException($"Redirect to '{baseAddress}' is not allowed."); + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs new file mode 100644 index 00000000..40809f0d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ClientBaseAdressResolver.cs @@ -0,0 +1,53 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class ClientBaseAddressResolver +{ + private readonly IReadOnlyList _providers; + + public ClientBaseAddressResolver(IEnumerable providers) + { + _providers = providers.ToList(); + } + + public string Resolve(HttpContext ctx, UAuthServerOptions options) + { + string? fallback = null; + + foreach (var provider in _providers) + { + if (!provider.TryResolve(ctx, options, out var candidate)) + continue; + + if (provider is IFallbackClientBaseAddressProvider) + { + fallback ??= candidate; + continue; + } + + return Validate(candidate, options); + } + + if (fallback is not null) + return Validate(fallback, options); + + throw new InvalidOperationException("Unable to resolve client base address from request."); + } + + private static string Validate(string baseAddress, UAuthServerOptions options) + { + if (options.Hub.AllowedClientOrigins.Count == 0) + return baseAddress; + + if (options.Hub.AllowedClientOrigins.Any(o => Normalize(o) == Normalize(baseAddress))) + return baseAddress; + + throw new InvalidOperationException($"Redirect to '{baseAddress}' is not allowed. " + + "The origin is not present in AllowedClientOrigins."); + } + + private static string Normalize(string uri) => uri.TrimEnd('/').ToLowerInvariant(); +} + diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs new file mode 100644 index 00000000..dec178d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ConfiguredClientBaseAddressProvider.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class ConfiguredClientBaseAddressProvider : IClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = default!; + + if (string.IsNullOrWhiteSpace(options.Hub.ClientBaseAddress)) + return false; + + baseAddress = options.Hub.ClientBaseAddress; + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs new file mode 100644 index 00000000..581b187c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IAuthRedirectResolver.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IAuthRedirectResolver +{ + RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext context); + RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext context, AuthFailureReason reason, LoginResult? loginResult = null); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs new file mode 100644 index 00000000..c0304d22 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IClientBaseAddressProvider.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal interface IClientBaseAddressProvider +{ + bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs new file mode 100644 index 00000000..69e352fa --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/IFallbackClientBaseAddressProvider.cs @@ -0,0 +1,5 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal interface IFallbackClientBaseAddressProvider : IClientBaseAddressProvider +{ +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs new file mode 100644 index 00000000..14843f36 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/OriginHeaderBaseAddressProvider.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class OriginHeaderBaseAddressProvider : IClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = default!; + + if (!context.Request.Headers.TryGetValue("Origin", out var origin)) + return false; + + if (!Uri.TryCreate(origin!, UriKind.Absolute, out var uri)) + return false; + + baseAddress = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs new file mode 100644 index 00000000..cbee309e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RedirectDecision.cs @@ -0,0 +1,23 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class RedirectDecision +{ + public bool Enabled { get; } + public string? TargetUrl { get; } + + private RedirectDecision(bool enabled, string? targetUrl) + { + Enabled = enabled; + TargetUrl = targetUrl; + } + + public static RedirectDecision None() => new(false, null); + + public static RedirectDecision To(string url) + { + if (string.IsNullOrWhiteSpace(url)) + throw new ArgumentException("Redirect target URL cannot be empty.", nameof(url)); + + return new RedirectDecision(true, url); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs new file mode 100644 index 00000000..50be0ee1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RefererHeaderBaseAddressProvider.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class RefererHeaderBaseAddressProvider : IClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = default!; + + if (!context.Request.Headers.TryGetValue("Referer", out var referer)) + return false; + + if (!Uri.TryCreate(referer!, UriKind.Absolute, out var uri)) + return false; + + baseAddress = uri.GetLeftPart(UriPartial.Authority); + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs new file mode 100644 index 00000000..646e8b7b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/RequestHostBaseAddressProvider.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class RequestHostBaseAddressProvider : IClientBaseAddressProvider, IFallbackClientBaseAddressProvider +{ + public bool TryResolve(HttpContext context, UAuthServerOptions options, out string baseAddress) + { + baseAddress = $"{context.Request.Scheme}://{context.Request.Host}"; + return true; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs new file mode 100644 index 00000000..fa169511 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlInfo.cs @@ -0,0 +1,28 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed record ReturnUrlInfo +{ + public ReturnUrlKind Kind { get; } + public string? RelativePath { get; } + public Uri? AbsoluteUri { get; } + + private ReturnUrlInfo(ReturnUrlKind kind, string? relative, Uri? absolute) + { + Kind = kind; + RelativePath = relative; + AbsoluteUri = absolute; + } + + public static ReturnUrlInfo None() + => new(ReturnUrlKind.None, null, null); + + public static ReturnUrlInfo Relative(string path) + => new(ReturnUrlKind.Relative, path, null); + + public static ReturnUrlInfo Absolute(Uri uri) + => new(ReturnUrlKind.Absolute, null, uri); + + public bool IsNone => Kind == ReturnUrlKind.None; + public bool IsRelative => Kind == ReturnUrlKind.Relative; + public bool IsAbsolute => Kind == ReturnUrlKind.Absolute; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs new file mode 100644 index 00000000..fa82e13d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlKind.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public enum ReturnUrlKind +{ + None, + Relative, + Absolute +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs new file mode 100644 index 00000000..ba204e7a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Redirect/ReturnUrlParser.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class ReturnUrlParser +{ + public static ReturnUrlInfo Parse(string? returnUrl) + { + if (string.IsNullOrWhiteSpace(returnUrl)) + return ReturnUrlInfo.None(); + + returnUrl = returnUrl.Trim(); + + if (returnUrl.StartsWith("/", StringComparison.Ordinal) || + returnUrl.StartsWith("./", StringComparison.Ordinal) || + returnUrl.StartsWith("../", StringComparison.Ordinal)) + { + return ReturnUrlInfo.Relative(returnUrl); + } + + if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var abs) && (abs.Scheme == Uri.UriSchemeHttp || abs.Scheme == Uri.UriSchemeHttps)) + { + return ReturnUrlInfo.Absolute(abs); + } + + if (returnUrl.StartsWith("//", StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid returnUrl."); + + throw new InvalidOperationException("Invalid returnUrl."); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs new file mode 100644 index 00000000..252a38ef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/ISessionContextAccessor.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +/// +/// The single point of truth for accessing the current session context +/// +public interface ISessionContextAccessor +{ + SessionContext? Current { get; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs new file mode 100644 index 00000000..da93972b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Session/SessionContextAccessor.cs @@ -0,0 +1,30 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class SessionContextAccessor : ISessionContextAccessor +{ + private readonly IHttpContextAccessor _httpContextAccessor; + + public SessionContextAccessor(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public SessionContext? Current + { + get + { + var ctx = _httpContextAccessor.HttpContext; + if (ctx is null) + return null; + + if (ctx.Items.TryGetValue(UAuthConstants.HttpItems.SessionContext, out var value)) + return value as SessionContext; + + return null; + } + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs new file mode 100644 index 00000000..daa4862d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/BearerSessionIdResolver.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class BearerSessionIdResolver : IInnerSessionIdResolver +{ + public string Name => "bearer"; + + public AuthSessionId? Resolve(HttpContext context) + { + var header = context.Request.Headers.Authorization.ToString(); + if (string.IsNullOrWhiteSpace(header)) + return null; + + if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + return null; + + var raw = header["Bearer ".Length..].Trim(); + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs new file mode 100644 index 00000000..1a687013 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CompositeSessionIdResolver.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +// TODO: Add policy and effective auth resolver. +public sealed class CompositeSessionIdResolver : ISessionIdResolver +{ + private readonly IReadOnlyDictionary _resolvers; + private readonly UAuthSessionResolutionOptions _options; + + public CompositeSessionIdResolver(IEnumerable resolvers, IOptions options) + { + _options = options.Value.SessionResolution; + _resolvers = resolvers.ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase); + } + + public AuthSessionId? Resolve(HttpContext context) + { + foreach (var name in _options.Order) + { + if (!IsEnabled(name)) + continue; + + if (!_resolvers.TryGetValue(name, out var resolver)) + continue; + + var id = resolver.Resolve(context); + if (id is not null) + return id; + } + + return null; + } + + private bool IsEnabled(string name) => name switch + { + "Bearer" => _options.EnableBearer, + "Header" => _options.EnableHeader, + "Cookie" => _options.EnableCookie, + "Query" => _options.EnableQuery, + _ => false + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs new file mode 100644 index 00000000..0d7bbaef --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/CookieSessionIdResolver.cs @@ -0,0 +1,34 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class CookieSessionIdResolver : IInnerSessionIdResolver +{ + public string Name => "cookie"; + + private readonly UAuthServerOptions _options; + + public CookieSessionIdResolver(IOptions options) + { + _options = options.Value; + } + + public AuthSessionId? Resolve(HttpContext context) + { + var cookieName = _options.Cookie.Session.Name; + + if (!context.Request.Cookies.TryGetValue(cookieName, out var raw)) + return null; + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs new file mode 100644 index 00000000..7b4fe2ad --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/HeaderSessionIdResolver.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class HeaderSessionIdResolver : IInnerSessionIdResolver +{ + public string Name => "header"; + private readonly UAuthServerOptions _options; + + public HeaderSessionIdResolver(IOptions options) + { + _options = options.Value; + } + + public AuthSessionId? Resolve(HttpContext context) + { + if (!context.Request.Headers.TryGetValue(_options.SessionResolution.HeaderName, out var values)) + return null; + + var raw = values.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs new file mode 100644 index 00000000..f09ea5cc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/IInnerSessionIdResolver.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IInnerSessionIdResolver +{ + string Name { get; } + AuthSessionId? Resolve(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs new file mode 100644 index 00000000..f46bbff3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/ISessionIdResolver.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface ISessionIdResolver +{ + AuthSessionId? Resolve(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs new file mode 100644 index 00000000..268bd1d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SessionId/QuerySessionIdResolver.cs @@ -0,0 +1,34 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class QuerySessionIdResolver : IInnerSessionIdResolver +{ + public string Name => "query"; + private readonly UAuthServerOptions _options; + + public QuerySessionIdResolver(IOptions options) + { + _options = options.Value; + } + + public AuthSessionId? Resolve(HttpContext context) + { + if (!context.Request.Query.TryGetValue(_options.SessionResolution.QueryParameterName, out var values)) + return null; + + var raw = values.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(raw)) + return null; + + if (!AuthSessionId.TryCreate(raw, out var sessionId)) + return null; + + return sessionId; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs new file mode 100644 index 00000000..3794ea24 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/SystemClock.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class SystemClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs new file mode 100644 index 00000000..ea149f56 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/UrlComposer.cs @@ -0,0 +1,21 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal static class UrlComposer +{ + public static string Combine(string baseUri, string path, IDictionary? query = null) + { + var url = baseUri.TrimEnd('/') + "/" + path.TrimStart('/'); + + if (query is null || query.Count == 0) + return url; + + var qs = string.Join("&", + query + .Where(kv => !string.IsNullOrWhiteSpace(kv.Value)) + .Select(kv => $"{kv.Key}={Uri.EscapeDataString(kv.Value!)}")); + + return string.IsNullOrWhiteSpace(qs) + ? url + : $"{url}?{qs}"; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs new file mode 100644 index 00000000..7dd0f3a0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/HttpContextCurrentUser.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class HttpContextCurrentUser : ICurrentUser +{ + private readonly IHttpContextAccessor _http; + + public HttpContextCurrentUser(IHttpContextAccessor http) + { + _http = http; + } + + public bool IsAuthenticated => Snapshot?.IsAuthenticated == true; + + public UserKey UserKey => Snapshot?.UserId ?? throw new InvalidOperationException("Current user is not authenticated."); + + private AuthUserSnapshot? Snapshot => _http.HttpContext?.Items[UAuthConstants.HttpItems.UserContextKey] as AuthUserSnapshot; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs new file mode 100644 index 00000000..0f14a8ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/IUserAccessor.cs @@ -0,0 +1,13 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IUserAccessor +{ + Task ResolveAsync(HttpContext context); +} + +public interface IUserAccessor +{ + Task ResolveAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs new file mode 100644 index 00000000..d02e74b3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserAccessor.cs @@ -0,0 +1,49 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Middlewares; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UAuthUserAccessor : IUserAccessor +{ + private readonly ISessionStoreFactory _kernelFactory; + private readonly IUserIdConverter _userIdConverter; + + public UAuthUserAccessor(ISessionStoreFactory kernelFactory, IUserIdConverterResolver converterResolver) + { + _kernelFactory = kernelFactory; + _userIdConverter = converterResolver.GetConverter(); + } + + public async Task ResolveAsync(HttpContext context) + { + var sessionCtx = context.GetSessionContext(); + + if (sessionCtx.IsAnonymous || sessionCtx.SessionId is null) + { + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Anonymous(); + return; + } + + if (sessionCtx.Tenant is not TenantKey tenant) + { + throw new InvalidOperationException("Tenant context is missing."); + } + + var kernel = _kernelFactory.Create(tenant); + var session = await kernel.GetSessionAsync(sessionCtx.SessionId.Value); + + if (session is null || session.IsRevoked) + { + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Anonymous(); + return; + } + + var userId = _userIdConverter.FromString(session.UserKey.Value); + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Authenticated(userId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs new file mode 100644 index 00000000..caf8cb45 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UAuthUserId.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public readonly record struct UAuthUserId(Guid Value) +{ + public override string ToString() => Value.ToString("N"); + + public static UAuthUserId New() => new(Guid.NewGuid()); + + public static implicit operator Guid(UAuthUserId id) => id.Value; + public static implicit operator UAuthUserId(Guid value) => new(value); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs new file mode 100644 index 00000000..17f95cb2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/User/UserAccessorBridge.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +internal sealed class UserAccessorBridge : IUserAccessor +{ + private readonly IServiceProvider _services; + + public UserAccessorBridge(IServiceProvider services) + { + _services = services; + } + + public async Task ResolveAsync(HttpContext context) + { + var accessor = _services.GetRequiredService>(); + await accessor.ResolveAsync(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs new file mode 100644 index 00000000..8054fa86 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IIdentifierValidator.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public interface IIdentifierValidator +{ + Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs new file mode 100644 index 00000000..627c73e1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/IdentifierValidator.cs @@ -0,0 +1,103 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Text.RegularExpressions; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class IdentifierValidator : IIdentifierValidator +{ + private readonly UAuthIdentifierValidationOptions _options; + + public IdentifierValidator(IOptions options) + { + _options = options.Value.IdentifierValidation; + } + + public Task ValidateAsync(AccessContext context, UserIdentifierInfo identifier, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var errors = new List(); + + if (string.IsNullOrWhiteSpace(identifier.Value)) + { + errors.Add(new("identifier_empty")); + return Task.FromResult(IdentifierValidationResult.Failed(errors)); + } + + identifier.Value = identifier.Value.Trim(); + + switch (identifier.Type) + { + case UserIdentifierType.Username: + ValidateUsername(identifier.Value, errors); + break; + + case UserIdentifierType.Email: + ValidateEmail(identifier.Value, errors); + break; + + case UserIdentifierType.Phone: + ValidatePhone(identifier.Value, errors); + break; + } + + if (errors.Count == 0) + return Task.FromResult(IdentifierValidationResult.Success()); + + return Task.FromResult(IdentifierValidationResult.Failed(errors)); + } + + private void ValidateUsername(string username, List errors) + { + var rule = _options.UserName; + + if (!rule.Enabled) + return; + + if (username.Length < rule.MinLength) + errors.Add(new("username_too_short", "username")); + + if (username.Length > rule.MaxLength) + errors.Add(new("username_too_long", "username")); + + if (!string.IsNullOrWhiteSpace(rule.AllowedRegex)) + { + if (!Regex.IsMatch(username, rule.AllowedRegex)) + errors.Add(new("username_invalid_format", "username")); + } + } + + private void ValidateEmail(string email, List errors) + { + var rule = _options.Email; + + if (!rule.Enabled) + return; + + if (email.Length < rule.MinLength) + errors.Add(new("email_too_short", "email")); + + if (email.Length > rule.MaxLength) + errors.Add(new("email_too_long", "email")); + + if (!email.Contains('@')) + errors.Add(new("email_invalid_format", "email")); + } + + private void ValidatePhone(string phone, List errors) + { + var rule = _options.Phone; + + if (!rule.Enabled) + return; + + if (phone.Length < rule.MinLength) + errors.Add(new("phone_too_short", "phone")); + + if (phone.Length > rule.MaxLength) + errors.Add(new("phone_too_long", "phone")); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs new file mode 100644 index 00000000..b3441fbe --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Infrastructure/Validator/UserCreateValidator.cs @@ -0,0 +1,65 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users; + +namespace CodeBeam.UltimateAuth.Server.Infrastructure; + +public sealed class UserCreateValidator : IUserCreateValidator +{ + private readonly IIdentifierValidator _identifierValidator; + + public UserCreateValidator(IIdentifierValidator identifierValidator) + { + _identifierValidator = identifierValidator; + } + + public async Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(request.UserName) && + string.IsNullOrWhiteSpace(request.Email) && + string.IsNullOrWhiteSpace(request.Phone)) + { + errors.Add(new("identifier_required")); + } + + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierInfo() + { + Type = UserIdentifierType.Username, + Value = request.UserName + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierInfo() + { + Type = UserIdentifierType.Email, + Value = request.Email + }, ct); + + errors.AddRange(r.Errors); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + var r = await _identifierValidator.ValidateAsync(context, new UserIdentifierInfo() + { + Type = UserIdentifierType.Phone, + Value = request.Phone + }, ct); + + errors.AddRange(r.Errors); + } + + if (errors.Count == 0) + return UserCreateValidatorResult.Success(); + + return UserCreateValidatorResult.Failed(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Internal/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs new file mode 100644 index 00000000..7e7d94d3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionResolutionMiddleware.cs @@ -0,0 +1,34 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public sealed class SessionResolutionMiddleware +{ + private readonly RequestDelegate _next; + + public SessionResolutionMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var sessionIdResolver = context.RequestServices.GetRequiredService(); + + var tenant = context.GetTenant(); + var sessionId = sessionIdResolver.Resolve(context); + + var sessionContext = sessionId is null + ? SessionContext.Anonymous() + : SessionContext.FromSessionId(sessionId.Value, tenant); + + context.Items[UAuthConstants.HttpItems.SessionContext] = sessionContext; + + await _next(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs new file mode 100644 index 00000000..45365641 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/SessionValidationMiddleware.cs @@ -0,0 +1,54 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public class SessionValidationMiddleware +{ + private readonly RequestDelegate _next; + private readonly ISessionValidator _validator; + private readonly IClock _clock; + + public SessionValidationMiddleware(RequestDelegate next, ISessionValidator validator, IClock clock) + { + _next = next; + _validator = validator; + _clock = clock; + } + + public async Task Invoke(HttpContext context) + { + var sessionCtx = context.GetSessionContext(); + + if (sessionCtx.IsAnonymous) + { + context.Items[UAuthConstants.HttpItems.SessionValidationResult] = SessionValidationResult.Invalid(SessionState.NotFound); + + await _next(context); + return; + } + + var info = await context.GetDeviceAsync(); + var device = DeviceContext.Create(info.DeviceId, info.DeviceType, info.Platform, info.OperatingSystem, info.Browser, info.IpAddress); + + if (sessionCtx.Tenant is not TenantKey tenant) + throw new InvalidOperationException("Tenant is not resolved."); + + var result = await _validator.ValidateSessionAsync(new SessionValidationContext + { + Tenant = tenant, + SessionId = sessionCtx.SessionId!.Value, + Now = _clock.UtcNow, + Device = device + }); + + context.Items["__UAuth.SessionValidationResult"] = result; + + await _next(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs new file mode 100644 index 00000000..6c2a97cf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/TenantMiddleware.cs @@ -0,0 +1,47 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.MultiTenancy; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public sealed class TenantMiddleware +{ + private readonly RequestDelegate _next; + + public TenantMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context, ITenantResolver resolver, IOptions options) + { + var opts = options.Value; + TenantResolutionResult resolution; + + if (!opts.Enabled) + { + context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.SingleTenant(); + await _next(context); + return; + } + + resolution = await resolver.ResolveAsync(context); + + // Middleware must allow unresolved tenants for non-auth requests. + // Exception should be handled only in AuthFlowContextFactory, where we can check if the request is for auth endpoints or not. + if (!resolution.IsResolved) + { + context.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Unresolved(); + await _next(context); + return; + } + + var tenantContext = UAuthTenantContext.Resolved(resolution.Tenant); + + context.Items[UAuthConstants.HttpItems.TenantContextKey] = tenantContext; + await _next(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs new file mode 100644 index 00000000..45904c73 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Middlewares/UserMiddleware.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Server.Middlewares; + +public sealed class UserMiddleware +{ + private readonly RequestDelegate _next; + + public UserMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var userAccessor = context.RequestServices.GetRequiredService(); + await userAccessor.ResolveAsync(context); + await _next(context); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs new file mode 100644 index 00000000..8cf41528 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/ITenantResolver.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; + +public interface ITenantResolver +{ + Task ResolveAsync(HttpContext context); +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs new file mode 100644 index 00000000..66341adf --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/TenantResolutionContextFactory.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; + +public static class TenantResolutionContextFactory +{ + public static TenantResolutionContext FromHttpContext(HttpContext ctx) + { + var headers = ctx.Request.Headers.ToDictionary(h => h.Key, h => h.Value.ToString(), StringComparer.OrdinalIgnoreCase); + var query = ctx.Request.Query.ToDictionary(q => q.Key, q => q.Value.ToString(), StringComparer.OrdinalIgnoreCase); + + return TenantResolutionContext.Create( + headers: headers, + Query: query, + host: ctx.Request.Host.Host, + path: ctx.Request.Path.Value, + rawContext: ctx + ); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs new file mode 100644 index 00000000..1612e00a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantContextFactory.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; + +public static class UAuthTenantContextFactory +{ + public static UAuthTenantContext Create(string? rawTenantId, UAuthMultiTenantOptions options) + { + if (!options.Enabled) + return UAuthTenantContext.SingleTenant(); + + if (string.IsNullOrWhiteSpace(rawTenantId)) + { + //if (options.RequireTenant) + // throw new InvalidOperationException("Tenant is required but could not be resolved."); + + throw new InvalidOperationException("Tenant could not be resolved."); + } + + var tenantId = options.NormalizeToLowercase + ? rawTenantId.Trim().ToLowerInvariant() + : rawTenantId.Trim(); + + var tenantKey = TenantKey.FromExternal(tenantId); + return UAuthTenantContext.Resolved(tenantKey); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs new file mode 100644 index 00000000..54d80e47 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/MultiTenancy/UAuthTenantResolver.cs @@ -0,0 +1,37 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.MultiTenancy; + +public sealed class UAuthTenantResolver : ITenantResolver +{ + private readonly ITenantIdResolver _idResolver; + private readonly UAuthMultiTenantOptions _options; + + public UAuthTenantResolver(ITenantIdResolver idResolver, IOptions options) + { + _idResolver = idResolver; + _options = options.Value; + } + + public async Task ResolveAsync(HttpContext context) + { + var resolutionContext =TenantResolutionContextFactory.FromHttpContext(context); + + var raw = await _idResolver.ResolveTenantIdAsync(resolutionContext); + + if (string.IsNullOrWhiteSpace(raw)) + return TenantResolutionResult.NotResolved(); + + var normalized = _options.NormalizeToLowercase + ? raw.Trim().ToLowerInvariant() + : raw.Trim(); + + if (!TenantKey.TryParse(normalized, null, out var tenant)) + return TenantResolutionResult.NotResolved(); + + return TenantResolutionResult.Resolved(tenant); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Options/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Options/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs new file mode 100644 index 00000000..e1245421 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/CredentialResponseOptions.cs @@ -0,0 +1,57 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class CredentialResponseOptions +{ + public GrantKind Kind { get; init; } + public TokenResponseMode Mode { get; set; } = TokenResponseMode.None; + + /// + /// Header or body name + /// + public string? Name { get; set; } + + /// + /// Applies when Mode = Header + /// + public HeaderTokenFormat HeaderFormat { get; set; } = HeaderTokenFormat.Bearer; + public TokenFormat TokenFormat { get; set; } + + // Only for cookie + public UAuthCookieOptions? Cookie { get; init; } + + internal CredentialResponseOptions Clone() => new() + { + Mode = Mode, + Name = Name, + HeaderFormat = HeaderFormat, + TokenFormat = TokenFormat, + Cookie = Cookie?.Clone() + }; + + public CredentialResponseOptions WithCookie(UAuthCookieOptions cookie) + { + if (Mode != TokenResponseMode.Cookie) + throw new InvalidOperationException("Cookie can only be set when Mode = Cookie."); + + return new CredentialResponseOptions() + { + Kind = Kind, + Mode = Mode, + Name = Name, + HeaderFormat = HeaderFormat, + TokenFormat = TokenFormat, + Cookie = cookie + }; + } + + public static CredentialResponseOptions Disabled(GrantKind kind) + => new() + { + Kind = kind, + Mode = TokenResponseMode.None + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs new file mode 100644 index 00000000..9b8d9f6c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Defaults/ConfigureDefaults.cs @@ -0,0 +1,140 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal class ConfigureDefaults +{ + internal static void ApplyModeDefaults(UAuthMode effectiveMode, UAuthServerOptions o) + { + switch (effectiveMode) + { + case UAuthMode.PureOpaque: + ApplyPureOpaqueDefaults(o); + break; + + case UAuthMode.Hybrid: + ApplyHybridDefaults(o); + break; + + case UAuthMode.SemiHybrid: + ApplySemiHybridDefaults(o); + break; + + case UAuthMode.PureJwt: + ApplyPureJwtDefaults(o); + break; + + default: + throw new InvalidOperationException($"Unsupported UAuthMode: {effectiveMode}"); + } + } + + private static void ApplyPureOpaqueDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Token; + var c = o.Cookie; + var r = o.AuthResponse; + + // Session behavior + s.SlidingExpiration = true; + + // Default: long-lived idle session (UX friendly) + s.IdleTimeout ??= TimeSpan.FromDays(7); + + s.TouchInterval ??= TimeSpan.FromDays(1); + + // Hard re-auth boundary is an advanced security feature + // Do NOT enable by default + s.MaxLifetime ??= null; + s.DeviceMismatchBehavior = DeviceMismatchBehavior.Allow; + + // SessionId is the primary opaque token, carried via cookie + t.IssueJwt = false; + + // No separate opaque access token is issued outside the session cookie + t.IssueOpaque = false; + + // Refresh token does not exist in PureOpaque + t.IssueRefresh = false; + + c.Session.Lifetime.IdleBuffer = TimeSpan.FromDays(2); + + r.RefreshTokenDelivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.None, + TokenFormat = TokenFormat.Opaque + }; + } + + private static void ApplyHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Token; + var c = o.Cookie; + var r = o.AuthResponse; + + s.SlidingExpiration = true; + s.TouchInterval = null; + + t.IssueJwt = true; + t.IssueOpaque = true; + t.AccessTokenLifetime = TimeSpan.FromMinutes(10); + t.RefreshTokenLifetime = TimeSpan.FromDays(7); + + c.Session.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + c.RefreshToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + + r.RefreshTokenDelivery = new CredentialResponseOptions + { + Mode = TokenResponseMode.Cookie, + TokenFormat = TokenFormat.Opaque + }; + } + + private static void ApplySemiHybridDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Token; + var p = o.Pkce; + var c = o.Cookie; + + s.SlidingExpiration = false; + s.TouchInterval = null; + + t.IssueJwt = true; + t.IssueOpaque = true; + t.AccessTokenLifetime = TimeSpan.FromMinutes(10); + t.RefreshTokenLifetime = TimeSpan.FromDays(7); + t.AddJwtIdClaim = true; + + c.AccessToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + c.RefreshToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + } + + private static void ApplyPureJwtDefaults(UAuthServerOptions o) + { + var s = o.Session; + var t = o.Token; + var p = o.Pkce; + var c = o.Cookie; + + s.TouchInterval = null; + + o.Session.SlidingExpiration = false; + o.Session.IdleTimeout = null; + o.Session.MaxLifetime = null; + + t.IssueJwt = true; + t.IssueOpaque = false; + t.AccessTokenLifetime = TimeSpan.FromMinutes(10); + t.RefreshTokenLifetime = TimeSpan.FromDays(7); + t.AddJwtIdClaim = true; + + c.AccessToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + c.RefreshToken.Lifetime.IdleBuffer = TimeSpan.FromMinutes(5); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs new file mode 100644 index 00000000..0eaf5182 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/IEffectiveServerOptionsProvider.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public interface IEffectiveServerOptionsProvider +{ + UAuthServerOptions GetOriginal(HttpContext context); + EffectiveUAuthServerOptions GetEffective(HttpContext context, AuthFlowType flowType, UAuthClientProfile clientProfile); + EffectiveUAuthServerOptions GetEffective(TenantKey tenant, AuthFlowType flowType, UAuthClientProfile clientProfile); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs new file mode 100644 index 00000000..b2fa0ef9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/LoginRedirectOptions.cs @@ -0,0 +1,37 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class LoginRedirectOptions +{ + public bool RedirectEnabled { get; set; } = true; + + public string SuccessRedirect { get; set; } = "/"; + public string FailureRedirect { get; set; } = "/login"; + + public string FailureQueryKey { get; set; } = "error"; + public string CodeQueryKey { get; set; } = "code"; + + public Dictionary FailureCodes { get; set; } = new(); + + /// + /// Whether query-based returnUrl override is allowed. + /// + public bool AllowReturnUrlOverride { get; set; } = true; + + public bool IncludeLockoutTiming { get; set; } = true; + public bool IncludeRemainingAttempts { get; set; } = false; + + internal LoginRedirectOptions Clone() => new() + { + RedirectEnabled = RedirectEnabled, + SuccessRedirect = SuccessRedirect, + FailureRedirect = FailureRedirect, + FailureQueryKey = FailureQueryKey, + CodeQueryKey = CodeQueryKey, + FailureCodes = new Dictionary(FailureCodes), + AllowReturnUrlOverride = AllowReturnUrlOverride, + IncludeLockoutTiming = IncludeLockoutTiming, + IncludeRemainingAttempts = IncludeRemainingAttempts + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs new file mode 100644 index 00000000..0126e299 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/LogoutRedirectOptions.cs @@ -0,0 +1,27 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class LogoutRedirectOptions +{ + /// + /// Whether logout endpoint performs a redirect. + /// + public bool RedirectEnabled { get; set; } = true; + + /// + /// Default redirect URL after logout. + /// + public string RedirectUrl { get; set; } = "/login"; + + /// + /// Whether query-based returnUrl override is allowed. + /// + public bool AllowReturnUrlOverride { get; set; } = true; + + internal LogoutRedirectOptions Clone() => new() + { + RedirectEnabled = RedirectEnabled, + RedirectUrl = RedirectUrl, + AllowReturnUrlOverride = AllowReturnUrlOverride + }; + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs new file mode 100644 index 00000000..41fdc042 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieLifetimeOptions.cs @@ -0,0 +1,22 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCookieLifetimeOptions +{ + /// + /// Extra lifetime added on top of the logical credential lifetime. + /// Prevents premature cookie eviction by the browser. + /// + public TimeSpan? IdleBuffer { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Allows developer to fully override cookie lifetime. + /// If set, buffer logic is ignored. + /// + public TimeSpan? AbsoluteLifetimeOverride { get; set; } + + internal UAuthCookieLifetimeOptions Clone() => new() + { + IdleBuffer = IdleBuffer, + AbsoluteLifetimeOverride = AbsoluteLifetimeOverride + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs new file mode 100644 index 00000000..cd0af2ed --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookieOptions.cs @@ -0,0 +1,41 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCookieOptions +{ + public string Name { get; set; } = default!; + + public bool HttpOnly { get; set; } = true; // TODO: Add UAUTH002 diagnostic if false? + + public CookieSecurePolicy SecurePolicy { get; set; } = CookieSecurePolicy.Always; + + public SameSiteMode? SameSite { get; set; } + + public string Path { get; set; } = "/"; + + /// + /// Optional cookie domain. + /// Use with caution. Null means host-only cookie. + /// + public string? Domain { get; set; } + + /// + /// If set, defines absolute expiration for the cookie. + /// If null, a session cookie is used. + /// + public TimeSpan? MaxAge { get; set; } + + public UAuthCookieLifetimeOptions Lifetime { get; set; } = new(); + + internal UAuthCookieOptions Clone() => new() + { + Name = Name, + HttpOnly = HttpOnly, + SecurePolicy = SecurePolicy, + SameSite = SameSite, + Path = Path, + MaxAge = MaxAge, + Lifetime = Lifetime.Clone() + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs new file mode 100644 index 00000000..d278ec00 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthCookiePolicyOptions.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthCookiePolicyOptions +{ + public UAuthCookieOptions Session { get; init; } = new() + { + Name = "uas", + HttpOnly = true, + }; + + public UAuthCookieOptions RefreshToken { get; init; } = new() + { + Name = "uar", + HttpOnly = true, + }; + + public UAuthCookieOptions AccessToken { get; init; } = new() + { + Name = "uat", + HttpOnly = true, + }; + + internal UAuthCookiePolicyOptions Clone() => new() + { + Session = Session.Clone(), + RefreshToken = RefreshToken.Clone(), + AccessToken = AccessToken.Clone() + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs new file mode 100644 index 00000000..46664b14 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthDiagnosticsOptions.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthDiagnosticsOptions +{ + /// + /// Enables debug / sample-only response headers such as X-UAuth-Refresh. If true, gives succesful refresh details. + /// Better to be disabled in production. + /// + public bool EnableRefreshDetails { get; set; } = false; + + internal UAuthDiagnosticsOptions Clone() => new() + { + EnableRefreshDetails = EnableRefreshDetails + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs new file mode 100644 index 00000000..2b90ae92 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubDeploymentMode.cs @@ -0,0 +1,26 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +/// +/// Describes how UAuthHub is deployed relative to the application. +/// This affects cookie SameSite, Secure requirements and auth flow defaults. +/// +public enum UAuthHubDeploymentMode +{ + /// + /// UAuthHub is embedded in the same application and same origin. + /// Example: Blazor Server app hosting auth endpoints internally. + /// + Embedded, + + /// + /// UAuthHub is hosted separately but within the same site boundary. + /// Example: auth.company.com and app.company.com behind same-site policy. + /// + Integrated, + + /// + /// UAuthHub is hosted on a different site / domain. + /// Example: auth.vendor.com used by app.company.com. + /// + External +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs new file mode 100644 index 00000000..c2f060d8 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthHubOptions.cs @@ -0,0 +1,24 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthHubOptions +{ + public string? ClientBaseAddress { get; set; } + + public HashSet AllowedClientOrigins { get; set; } = new(); + + /// + /// Lifetime of hub flow artifacts (UI orchestration). + /// Should be short-lived. + /// + public TimeSpan FlowLifetime { get; set; } = TimeSpan.FromMinutes(5); + + public string? LoginPath { get; set; } = "/login"; + + internal UAuthHubOptions Clone() => new() + { + ClientBaseAddress = ClientBaseAddress, + AllowedClientOrigins = new HashSet(AllowedClientOrigins), + FlowLifetime = FlowLifetime, + LoginPath = LoginPath + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs new file mode 100644 index 00000000..1d7c9c6a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierOptions.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthIdentifierOptions +{ + public bool AllowUsernameChange { get; set; } = true; + public bool AllowMultipleUsernames { get; set; } = false; + public bool AllowMultipleEmail { get; set; } = true; + public bool AllowMultiplePhone { get; set; } = true; + + public bool RequireUsernameIdentifier { get; set; } = false; + public bool RequireEmailVerification { get; set; } = false; + public bool RequirePhoneVerification { get; set; } = false; + + public bool AllowAdminOverride { get; set; } = true; + public bool AllowUserOverride { get; set; } = true; + + internal UAuthIdentifierOptions Clone() => new() + { + AllowUsernameChange = AllowUsernameChange, + AllowMultipleUsernames = AllowMultipleUsernames, + AllowMultipleEmail = AllowMultipleEmail, + AllowMultiplePhone = AllowMultiplePhone, + RequireUsernameIdentifier = RequireUsernameIdentifier, + RequireEmailVerification = RequireEmailVerification, + RequirePhoneVerification = RequirePhoneVerification, + AllowAdminOverride = AllowAdminOverride, + AllowUserOverride = AllowUserOverride + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs new file mode 100644 index 00000000..612c518a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthIdentifierValidationOptions.cs @@ -0,0 +1,59 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthIdentifierValidationOptions +{ + public UsernameIdentifierRule UserName { get; set; } = new(); + public EmailIdentifierRule Email { get; set; } = new(); + public PhoneIdentifierRule Phone { get; set; } = new(); + + internal UAuthIdentifierValidationOptions Clone() => new() + { + UserName = UserName.Clone(), + Email = Email.Clone(), + Phone = Phone.Clone() + }; +} + +public sealed class UsernameIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 64; + public string? AllowedRegex { get; set; } = "^[a-zA-Z0-9._-]+$"; + + internal UsernameIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + AllowedRegex = AllowedRegex, + }; +} + +public sealed class EmailIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 256; + + internal EmailIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + }; +} + +public sealed class PhoneIdentifierRule +{ + public bool Enabled { get; set; } = true; + public int MinLength { get; set; } = 3; + public int MaxLength { get; set; } = 24; + + internal PhoneIdentifierRule Clone() => new() + { + Enabled = Enabled, + MinLength = MinLength, + MaxLength = MaxLength, + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs new file mode 100644 index 00000000..07df6483 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthLoginIdentifierOptions.cs @@ -0,0 +1,49 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Options; +public sealed class UAuthLoginIdentifierOptions +{ + public ISet AllowedTypes { get; set; } = + new HashSet + { + UserIdentifierType.Username, + UserIdentifierType.Email, + UserIdentifierType.Phone + }; + + public bool RequireVerificationForEmail { get; set; } = false; + public bool RequireVerificationForPhone { get; set; } = false; + + public bool EnableCustomResolvers { get; set; } = true; + public bool CustomResolversFirst { get; set; } = true; + + public UAuthIdentifierNormalizationOptions Normalization { get; set; } = new(); + + public bool EnforceGlobalUniquenessForAllIdentifiers { get; set; } = false; + + internal UAuthLoginIdentifierOptions Clone() => new() + { + AllowedTypes = new HashSet(AllowedTypes), + RequireVerificationForEmail = RequireVerificationForEmail, + RequireVerificationForPhone = RequireVerificationForPhone, + EnableCustomResolvers = EnableCustomResolvers, + CustomResolversFirst = CustomResolversFirst, + EnforceGlobalUniquenessForAllIdentifiers = EnforceGlobalUniquenessForAllIdentifiers, + Normalization = Normalization.Clone() + }; +} + +public sealed class UAuthIdentifierNormalizationOptions +{ + public CaseHandling UsernameCase { get; set; } = CaseHandling.ToLower; + public CaseHandling EmailCase { get; set; } = CaseHandling.ToLower; + public CaseHandling CustomCase { get; set; } = CaseHandling.Preserve; + + internal UAuthIdentifierNormalizationOptions Clone() => new() + { + UsernameCase = UsernameCase, + EmailCase = EmailCase, + CustomCase = CustomCase + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthNavigationOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthNavigationOptions.cs new file mode 100644 index 00000000..9f1e765e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthNavigationOptions.cs @@ -0,0 +1,17 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthNavigationOptions +{ + public bool EnableAutomaticNavigationRedirect { get; set; } = true; + public Func? LoginResolver { get; set; } + public Func? AccessDeniedResolver { get; set; } + + internal UAuthNavigationOptions Clone() => new() + { + EnableAutomaticNavigationRedirect = EnableAutomaticNavigationRedirect, + LoginResolver = LoginResolver, + AccessDeniedResolver = AccessDeniedResolver, + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs new file mode 100644 index 00000000..685bd2a3 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthPrimaryCredentialPolicy.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthPrimaryCredentialPolicy +{ + /// + /// Default primary credential for UI-style requests. + /// + public PrimaryGrantKind Ui { get; set; } = PrimaryGrantKind.Stateful; + + /// + /// Default primary credential for API requests. + /// + public PrimaryGrantKind Api { get; set; } = PrimaryGrantKind.Stateless; + + internal UAuthPrimaryCredentialPolicy Clone() => new() + { + Ui = Ui, + Api = Api + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs new file mode 100644 index 00000000..9abeec8d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResetOptions.cs @@ -0,0 +1,22 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthResetOptions +{ + public TimeSpan TokenValidity { get; set; } = TimeSpan.FromMinutes(30); + public TimeSpan CodeValidity { get; set; } = TimeSpan.FromMinutes(10); + public int MaxAttempts { get; set; } = 3; + + /// + /// Gets or sets the length for numeric reset codes. Does not affect token-based resets. + /// Default is 6, which means the code will be a 6-digit number. + /// + public int CodeLength { get; set; } = 6; + + internal UAuthResetOptions Clone() => new() + { + TokenValidity = TokenValidity, + CodeValidity = CodeValidity, + MaxAttempts = MaxAttempts, + CodeLength = CodeLength + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs new file mode 100644 index 00000000..2fb51a20 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResourceApiOptions.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public class UAuthResourceApiOptions +{ + public string UAuthHubBaseUrl { get; set; } = default!; + public HashSet AllowedClientOrigins { get; set; } = new(); + public string CorsPolicyName { get; set; } = "UAuthResource"; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs new file mode 100644 index 00000000..9297b557 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthResponseOptions.cs @@ -0,0 +1,20 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthResponseOptions +{ + public CredentialResponseOptions SessionIdDelivery { get; set; } = new(); + public CredentialResponseOptions AccessTokenDelivery { get; set; } = new(); + public CredentialResponseOptions RefreshTokenDelivery { get; set; } = new(); + + public LoginRedirectOptions Login { get; set; } = new(); + public LogoutRedirectOptions Logout { get; set; } = new(); + + internal UAuthResponseOptions Clone() => new() + { + SessionIdDelivery = SessionIdDelivery.Clone(), + AccessTokenDelivery = AccessTokenDelivery.Clone(), + RefreshTokenDelivery = RefreshTokenDelivery.Clone(), + Login = Login.Clone(), + Logout = Logout.Clone() + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs new file mode 100644 index 00000000..ee34540f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerEndpointOptions.cs @@ -0,0 +1,42 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerEndpointOptions +{ + /// + /// Base API route. Default: "/auth" + /// Changing this prevents conflicts with other auth systems. + /// + public string BasePath { get; set; } = "/auth"; + + public bool Authentication { get; set; } = true; + public bool Pkce { get; set; } = true; + //public bool Token { get; set; } = true; + public bool Session { get; set; } = true; + + //public bool UserInfo { get; set; } = true; + public bool UserLifecycle { get; set; } = true; + public bool UserProfile { get; set; } = true; + public bool UserIdentifier { get; set; } = true; + public bool Credentials { get; set; } = true; + + public bool Authorization { get; set; } = true; + + public HashSet DisabledActions { get; set; } = new(); + + public bool IsDisabled(string action) => DisabledActions.Contains(action); + + internal UAuthServerEndpointOptions Clone() => new() + { + Authentication = Authentication, + Pkce = Pkce, + //Token = Token, + Session = Session, + //UserInfo = UserInfo, + UserLifecycle = UserLifecycle, + UserProfile = UserProfile, + UserIdentifier = UserIdentifier, + Credentials = Credentials, + Authorization = Authorization, + DisabledActions = new HashSet(DisabledActions) + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs new file mode 100644 index 00000000..6706f0d4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthServerOptions.cs @@ -0,0 +1,162 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Routing; + +namespace CodeBeam.UltimateAuth.Server.Options; + +/// +/// Server-side configuration for UltimateAuth. +/// Does NOT duplicate Core options. +/// Instead, it composes SessionOptions, TokenOptions, PkceOptions, MultiTenantOptions +/// and adds server-only behaviors (routing, endpoint activation, policies). +/// +public sealed class UAuthServerOptions +{ + + // ------------------------------------------------------- + // CORE OPTION COMPOSITION + // (Server must NOT duplicate Core options) + // ------------------------------------------------------- + + public UAuthLoginOptions Login { get; set; } = new(); + + /// + /// Session behavior (lifetime, sliding expiration, etc.) Fully defined in Core. + /// + public UAuthSessionOptions Session { get; set; } = new(); + + /// + /// Token issuing behavior (lifetimes, refresh policies). Fully defined in Core. + /// + public UAuthTokenOptions Token { get; set; } = new(); + + /// + /// PKCE configuration (required for WASM). Fully defined in Core. + /// + public UAuthPkceOptions Pkce { get; set; } = new(); + + public UAuthEvents Events { get; set; } = new(); + + /// + /// Multi-tenancy behavior (resolver, normalization, etc.) Fully defined in Core. + /// + public UAuthMultiTenantOptions MultiTenant { get; set; } = new(); + + // ------------------------------------------------------- + // SERVER-ONLY BEHAVIOR + // ------------------------------------------------------- + + /// + /// Defines which authentication modes are allowed to be used by the server. + /// This is a safety guardrail, not a mode selection mechanism. + /// The final mode is still resolved via IEffectiveAuthModeResolver. + /// If null or empty, all modes are allowed. + /// + public IReadOnlyCollection? AllowedModes { get; set; } + + /// + /// Defines how UAuthHub is deployed relative to the application. + /// Default is Integrated + /// Blazor server projects should choose embedded mode for maximum security. + /// + public UAuthHubDeploymentMode HubDeploymentMode { get; set; } = UAuthHubDeploymentMode.Integrated; + + /// + /// Allows advanced users to override cookie behavior. + /// Unsafe combinations will be rejected at startup. + /// + public UAuthCookiePolicyOptions Cookie { get; set; } = new(); + + public UAuthDiagnosticsOptions Diagnostics { get; set; } = new(); + + public UAuthPrimaryCredentialPolicy PrimaryCredential { get; init; } = new(); + + public UAuthResponseOptions AuthResponse { get; init; } = new(); + + public UAuthResetOptions ResetCredential { get; init; } = new(); + + public UAuthHubOptions Hub { get; set; } = new(); + + /// + /// Controls how session identifiers are resolved from incoming requests + /// (cookie, header, bearer, query, order, etc.) + /// + public UAuthSessionResolutionOptions SessionResolution { get; set; } = new(); + + /// + /// Enables/disables specific endpoint groups. Useful for API hardening. + /// + public UAuthServerEndpointOptions Endpoints { get; set; } = new(); + + public UAuthIdentifierOptions Identifiers { get; set; } = new(); + + public UAuthIdentifierValidationOptions IdentifierValidation { get; set; } = new(); + + public UAuthLoginIdentifierOptions LoginIdentifiers { get; set; } = new(); + + public UAuthNavigationOptions Navigation { get; set; } = new(); + + + ///// + ///// If true, server will add anti-forgery headers + ///// and require proper request metadata. + ///// + //public bool EnableAntiCsrfProtection { get; set; } = true; + + ///// + ///// If true, login attempts are rate-limited to prevent brute force attacks. + ///// + //public bool EnableLoginRateLimiting { get; set; } = true; + + + // ------------------------------------------------------- + // CUSTOMIZATION HOOKS + // ------------------------------------------------------- + + /// + /// Allows developers to mutate endpoint routing AFTER UltimateAuth registers defaults like + /// adding new routes, overriding authorization, adding filters. + /// This hook must not remove or re-map UltimateAuth endpoints. Misuse may break security guarantees. + /// + public Action? OnConfigureEndpoints { get; set; } + + internal Dictionary> ModeConfigurations { get; set; } = new(); + + + internal UAuthServerOptions Clone() + { + return new UAuthServerOptions + { + AllowedModes = AllowedModes?.ToArray(), + HubDeploymentMode = HubDeploymentMode, + + Login = Login.Clone(), + Session = Session.Clone(), + Token = Token.Clone(), + Pkce = Pkce.Clone(), + Events = Events.Clone(), + MultiTenant = MultiTenant.Clone(), + Cookie = Cookie.Clone(), + Diagnostics = Diagnostics.Clone(), + + PrimaryCredential = PrimaryCredential.Clone(), + AuthResponse = AuthResponse.Clone(), + ResetCredential = ResetCredential.Clone(), + Hub = Hub.Clone(), + SessionResolution = SessionResolution.Clone(), + Identifiers = Identifiers.Clone(), + IdentifierValidation = IdentifierValidation.Clone(), + LoginIdentifiers = LoginIdentifiers.Clone(), + Endpoints = Endpoints.Clone(), + Navigation = Navigation.Clone(), + + //EnableAntiCsrfProtection = EnableAntiCsrfProtection, + //EnableLoginRateLimiting = EnableLoginRateLimiting, + + ModeConfigurations = new Dictionary>(ModeConfigurations), + + OnConfigureEndpoints = OnConfigureEndpoints, + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs new file mode 100644 index 00000000..f36b16bd --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/UAuthSessionResolutionOptions.cs @@ -0,0 +1,35 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Options; + +// TODO: Check header/query parameter name conflicts with other auth mechanisms (e.g. API keys, OAuth tokens) +// We removed CookieName here because cookie-based session resolution, there may be other conflicts. +public sealed class UAuthSessionResolutionOptions +{ + public bool EnableBearer { get; set; } = true; + public bool EnableHeader { get; set; } = true; + public bool EnableCookie { get; set; } = true; + public bool EnableQuery { get; set; } = true; + + public string HeaderName { get; set; } = "X-UAuth-Session"; + public string QueryParameterName { get; set; } = "session_id"; + + // Precedence order + // Example: Bearer, Header, Cookie, Query + public List Order { get; set; } = new() + { + "Bearer", + "Header", + "Cookie", + "Query" + }; + + internal UAuthSessionResolutionOptions Clone() => new() + { + EnableBearer = EnableBearer, + EnableHeader = EnableHeader, + EnableCookie = EnableCookie, + EnableQuery = EnableQuery, + HeaderName = HeaderName, + QueryParameterName = QueryParameterName, + Order = new List(Order) + }; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs new file mode 100644 index 00000000..962cb852 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerLoginOptionsValidator.cs @@ -0,0 +1,22 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerLoginOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var login = options.Login; + + if (login.MaxFailedAttempts < 0) + errors.Add("Login.MaxFailedAttempts cannot be negative."); + + if (login.LockoutDuration < TimeSpan.Zero) + errors.Add("Login.LockoutMinutes cannot be negative."); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs new file mode 100644 index 00000000..ec3afed5 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerMultiTenantOptionsValidator.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Core.Options; + +internal sealed class UAuthServerMultiTenantOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var multiTenant = options.MultiTenant; + + if (!multiTenant.Enabled) + { + if (multiTenant.EnableRoute || multiTenant.EnableHeader || multiTenant.EnableDomain) + { + errors.Add("Multi-tenancy is disabled, but one or more tenant resolvers are enabled. " + + "Either enable multi-tenancy or disable all tenant resolvers."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } + + if (!multiTenant.EnableRoute && !multiTenant.EnableHeader && !multiTenant.EnableDomain) + { + errors.Add("Multi-tenancy is enabled but no tenant resolver is active. " + + "Enable at least one of: route, header or domain."); + } + + if (multiTenant.EnableHeader) + { + if (string.IsNullOrWhiteSpace(multiTenant.HeaderName)) + { + errors.Add("MultiTenant.HeaderName must be specified when header-based tenant resolution is enabled."); + } + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs new file mode 100644 index 00000000..5b1fe8bc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerOptionsValidator.cs @@ -0,0 +1,72 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + if (string.IsNullOrWhiteSpace(options.Endpoints.BasePath)) + { + return ValidateOptionsResult.Fail( "BasePath must be specified."); + } + + if (options.Endpoints.BasePath.Contains("//")) + { + return ValidateOptionsResult.Fail("BasePath cannot contain '//'."); + } + + var allowedModes = options.AllowedModes; + + if (allowedModes is { Count: > 0 }) + { + foreach (var mode in allowedModes) + { + if (!Enum.IsDefined(typeof(UAuthMode), mode)) + { + return ValidateOptionsResult.Fail($"Invalid UAuthMode value: {mode}"); + } + + // TODO: Delete here when SemiHybrid and PureJwt modes are implemented. + if (mode is UAuthMode.SemiHybrid or UAuthMode.PureJwt) + { + return ValidateOptionsResult.Fail($"Auth mode '{mode}' is not implemented yet and cannot be enabled."); + } + } + } + + bool anySessionModeAllowed = allowedModes is null || allowedModes.Count == 0 || + allowedModes.Contains(UAuthMode.Hybrid) || allowedModes.Contains(UAuthMode.PureOpaque) || allowedModes.Contains(UAuthMode.SemiHybrid); + + if (anySessionModeAllowed) + { + if (options.Session.Lifetime <= TimeSpan.Zero) + { + return ValidateOptionsResult.Fail( + "Session.Lifetime must be greater than zero."); + } + + if (options.Session.MaxLifetime is not null && + options.Session.MaxLifetime <= TimeSpan.Zero) + { + return ValidateOptionsResult.Fail( + "Session.MaxLifetime must be greater than zero when specified."); + } + } + + + // Only add cross-option validation beyond this point, individual options should validate in their own validators. + if (options.Token!.AccessTokenLifetime > options.Session!.MaxLifetime) + { + return ValidateOptionsResult.Fail("Token.AccessTokenLifetime cannot exceed Session.MaxLifetime."); + } + + if (options.Token.RefreshTokenLifetime > options.Session.MaxLifetime) + { + return ValidateOptionsResult.Fail("Token.RefreshTokenLifetime cannot exceed Session.MaxLifetime."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs new file mode 100644 index 00000000..b74a6d3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerPkceOptionsValidator.cs @@ -0,0 +1,20 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerPkceOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + + if (options.Pkce.AuthorizationCodeLifetimeSeconds <= 0) + { + errors.Add("Pkce.AuthorizationCodeLifetimeSeconds must be > 0."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs new file mode 100644 index 00000000..192b2786 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionOptionsValidator.cs @@ -0,0 +1,29 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerSessionOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var session = options.Session; + + if (session.Lifetime <= TimeSpan.Zero) + errors.Add("Session.Lifetime must be greater than zero."); + + if (session.MaxLifetime.HasValue && session.MaxLifetime <= TimeSpan.Zero) + errors.Add("Session.MaxLifetime must be greater than zero when specified."); + + if (session.MaxLifetime.HasValue && + session.MaxLifetime < session.Lifetime) + errors.Add("Session.MaxLifetime must be greater than or equal to Session.Lifetime."); + + if (session.IdleTimeout.HasValue && session.IdleTimeout < TimeSpan.Zero) + errors.Add("Session.IdleTimeout cannot be negative."); + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs new file mode 100644 index 00000000..76058c11 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerSessionResolutionOptionsValidator.cs @@ -0,0 +1,61 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerSessionResolutionOptionsValidator : IValidateOptions +{ + private static readonly HashSet KnownResolvers = + new(StringComparer.OrdinalIgnoreCase) + { + "Bearer", + "Header", + "Cookie", + "Query" + }; + + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var o = options.SessionResolution; + + if (!o.EnableBearer && !o.EnableHeader && !o.EnableCookie && !o.EnableQuery) + { + return ValidateOptionsResult.Fail("At least one session resolver must be enabled (Bearer, Header, Cookie, Query)."); + } + + if (o.Order is null || o.Order.Count == 0) + { + return ValidateOptionsResult.Fail("SessionResolution.Order cannot be empty."); + } + + foreach (var item in o.Order) + { + if (!KnownResolvers.Contains(item)) + { + return ValidateOptionsResult.Fail($"Unknown session resolver '{item}' in SessionResolution.Order."); + } + } + + foreach (var item in o.Order) + { + if (item.Equals("Bearer", StringComparison.OrdinalIgnoreCase) && !o.EnableBearer || + item.Equals("Header", StringComparison.OrdinalIgnoreCase) && !o.EnableHeader || + item.Equals("Cookie", StringComparison.OrdinalIgnoreCase) && !o.EnableCookie || + item.Equals("Query", StringComparison.OrdinalIgnoreCase) && !o.EnableQuery) + { + return ValidateOptionsResult.Fail($"Session resolver '{item}' is listed in Order but is not enabled."); + } + } + + if (o.EnableHeader && string.IsNullOrWhiteSpace(o.HeaderName)) + { + return ValidateOptionsResult.Fail("SessionResolution.HeaderName must be specified when header resolver is enabled."); + } + + if (o.EnableQuery && string.IsNullOrWhiteSpace(o.QueryParameterName)) + { + return ValidateOptionsResult.Fail("SessionResolution.QueryParameterName must be specified when query resolver is enabled."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs new file mode 100644 index 00000000..129b62b1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerTokenOptionsValidator.cs @@ -0,0 +1,49 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +internal sealed class UAuthServerTokenOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + var errors = new List(); + var tokens = options.Token; + + if (!tokens.IssueJwt && !tokens.IssueOpaque) + errors.Add("Token: At least one of IssueJwt or IssueOpaque must be enabled."); + + if (tokens.AccessTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.AccessTokenLifetime must be greater than zero."); + + if (tokens.IssueRefresh) + { + if (tokens.RefreshTokenLifetime <= TimeSpan.Zero) + errors.Add("Token.RefreshTokenLifetime must be greater than zero when IssueRefresh is enabled."); + + if (tokens.RefreshTokenLifetime <= tokens.AccessTokenLifetime) + errors.Add("Token.RefreshTokenLifetime must be greater than Token.AccessTokenLifetime."); + } + + if (tokens.IssueJwt) + { + if (string.IsNullOrWhiteSpace(tokens.Issuer) || tokens.Issuer.Trim().Length < 3) + errors.Add("Token.Issuer must be at least 3 characters when IssueJwt is enabled."); + + if (string.IsNullOrWhiteSpace(tokens.Audience) || tokens.Audience.Trim().Length < 3) + errors.Add("Token.Audience must be at least 3 characters when IssueJwt is enabled."); + } + + if (tokens.IssueOpaque) + { + if (tokens.OpaqueIdBytes < 16) + errors.Add("Token.OpaqueIdBytes must be at least 16 bytes (128-bit entropy)."); + + if (tokens.OpaqueIdBytes > 128) + errors.Add("Token.OpaqueIdBytes must not exceed 64 bytes."); + } + + return errors.Count == 0 + ? ValidateOptionsResult.Success + : ValidateOptionsResult.Fail(errors); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs new file mode 100644 index 00000000..3e8ce27b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Options/Validators/UAuthServerUserIdentifierOptionsValidator.cs @@ -0,0 +1,17 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Options; + +public sealed class UAuthServerUserIdentifierOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthServerOptions options) + { + if (!options.Identifiers.AllowAdminOverride && !options.Identifiers.AllowUserOverride) + { + return ValidateOptionsResult.Fail("Both AllowAdminOverride and AllowUserOverride cannot be false. " + + "At least one actor must be able to manage user identifiers."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/README.md b/src/CodeBeam.UltimateAuth.Server/README.md new file mode 100644 index 00000000..6b7d5314 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/README.md @@ -0,0 +1,25 @@ +๏ปฟ# UltimateAuth Server + +The main backend package for UltimateAuth. + +## What this package includes + +- Authentication core +- Users module +- Credentials module +- Authorization (roles & permissions) +- Policies (authorization logic) + +## Notes + +This package automatically includes all required core modules. + +You do NOT need to install individual packages like: + +- Core +- Users +- Credentials +- Authorization +- Policies + +unless you are building custom integrations. (But still need reference and persistence packages) \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs new file mode 100644 index 00000000..f5bab9db --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/RemoteSessionValidator.cs @@ -0,0 +1,50 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using Microsoft.AspNetCore.Http; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class RemoteSessionValidator : ISessionValidator +{ + private readonly HttpClient _http; + private readonly IHttpContextAccessor _httpContextAccessor; + + public RemoteSessionValidator(HttpClient http, IHttpContextAccessor httpContextAccessor) + { + _http = http; + _httpContextAccessor = httpContextAccessor; + } + + public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) + { + var request = new HttpRequestMessage(HttpMethod.Post, "/auth/validate") + { + Content = JsonContent.Create(new + { + sessionId = context.SessionId.Value, + tenant = context.Tenant.Value + }) + }; + + var httpContext = _httpContextAccessor.HttpContext!; + + if (httpContext.Request.Headers.TryGetValue("Cookie", out var cookie)) + { + request.Headers.Add("Cookie", cookie.ToString()); + } + + var response = await _http.SendAsync(request, ct); + + if (!response.IsSuccessStatusCode) + return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); + + var dto = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + + if (dto is null) + return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); + + return SessionValidationMapper.ToDomain(dto); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs new file mode 100644 index 00000000..7569b9e7 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceAuthContextFactory.cs @@ -0,0 +1,61 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class ResourceAuthContextFactory : IAuthContextFactory +{ + private readonly IHttpContextAccessor _http; + private readonly IClock _clock; + + public ResourceAuthContextFactory(IHttpContextAccessor http, IClock clock) + { + _http = http; + _clock = clock; + } + + public AuthContext Create(DateTimeOffset? at = null) + { + var ctx = _http.HttpContext!; + + var result = ctx.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult; + + if (result is null || !result.IsValid) + { + return new AuthContext + { + Tenant = default!, + Operation = AuthOperation.ResourceAccess, + Mode = UAuthMode.PureOpaque, + ClientProfile = UAuthClientProfile.Api, + Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)), + At = at ?? _clock.UtcNow, + Session = null + }; + } + + return new AuthContext + { + Tenant = result.Tenant, + Operation = AuthOperation.ResourceAccess, + Mode = UAuthMode.PureOpaque, // sonra resolver yapฤฑlabilir + ClientProfile = UAuthClientProfile.Api, + Device = DeviceContext.Create(DeviceId.Create(result.BoundDeviceId.Value.Value)), + At = at ?? _clock.UtcNow, + + Session = new SessionSecurityContext + { + UserKey = result.UserKey, + SessionId = result.SessionId.Value, + State = result.State, + ChainId = result.ChainId, + BoundDeviceId = result.BoundDeviceId + } + }; + } +} \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs new file mode 100644 index 00000000..4c0fb2b9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/ResourceApi/ResourceUserAccessor.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Server.ResourceApi; + +internal sealed class ResourceUserAccessor : IUserAccessor +{ + private readonly IUserIdConverter _converter; + + public ResourceUserAccessor(IUserIdConverterResolver resolver) + { + _converter = resolver.GetConverter(); + } + + public Task ResolveAsync(HttpContext context) + { + var result = context.Items[UAuthConstants.HttpItems.SessionValidationResult] as SessionValidationResult; + + if (result is null || !result.IsValid || result.UserKey is null) + { + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Anonymous(); + return Task.CompletedTask; + } + + var userId = _converter.FromString(result.UserKey.Value); + context.Items[UAuthConstants.HttpItems.UserContextKey] = AuthUserSnapshot.Authenticated(userId); + + return Task.CompletedTask; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/IUAuthServerProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/IUAuthServerProductInfoProvider.cs new file mode 100644 index 00000000..99c79ab2 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/IUAuthServerProductInfoProvider.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Runtime; + +public interface IUAuthServerProductInfoProvider +{ + UAuthServerProductInfo Get(); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/ResourceRuntimeMarker.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/ResourceRuntimeMarker.cs new file mode 100644 index 00000000..c6ba922d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/ResourceRuntimeMarker.cs @@ -0,0 +1,7 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; + +namespace CodeBeam.UltimateAuth.Server.Runtime; + +internal class ResourceRuntimeMarker : IUAuthRuntimeMarker +{ +} diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs new file mode 100644 index 00000000..922c53c4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/ServerRuntimeMarker.cs @@ -0,0 +1,7 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Runtime; + +namespace CodeBeam.UltimateAuth.Server.Runtime; + +internal sealed class ServerRuntimeMarker : IUAuthRuntimeMarker +{ +} diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs new file mode 100644 index 00000000..b327dfe4 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfo.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Server.Runtime; + +public sealed class UAuthServerProductInfo +{ + public string ProductName { get; init; } = "UltimateAuth Server"; + public string Version { get; init; } = default!; + public string? InformationalVersion { get; init; } + + public DateTimeOffset StartedAt { get; init; } + public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); + + public UAuthHubDeploymentMode HubDeploymentMode { get; init; } + public bool MultiTenancyEnabled { get; init; } + public string? FrameworkDescription { get; set; } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs new file mode 100644 index 00000000..de9a7e0a --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Runtime/UAuthServerProductInfoProvider.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Server.Runtime; + +internal sealed class UAuthServerProductInfoProvider : IUAuthServerProductInfoProvider +{ + private readonly UAuthServerProductInfo _info; + + public UAuthServerProductInfoProvider(IOptions serverOptions) + { + var asm = typeof(UAuthServerProductInfoProvider).Assembly; + + _info = new UAuthServerProductInfo + { + Version = asm.GetName().Version?.ToString(3) ?? "unknown", + InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, + StartedAt = DateTimeOffset.UtcNow, + + HubDeploymentMode = serverOptions.Value.HubDeploymentMode, + MultiTenancyEnabled = serverOptions.Value.MultiTenant.Enabled, + FrameworkDescription = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription + }; + } + + public UAuthServerProductInfo Get() => _info; +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Services/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Services/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs new file mode 100644 index 00000000..71185aab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IHubFlowService.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Contracts; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface IHubFlowService +{ + Task BeginLoginAsync(HubBeginRequest request, CancellationToken ct = default); + + Task ContinuePkceAsync(string hubSessionId, string authorizationCode, string codeVerifier, CancellationToken ct = default); + + Task ConsumeAsync(string hubSessionId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs new file mode 100644 index 00000000..e2c3c6f0 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IPkceService.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface IPkceService +{ + Task AuthorizeAsync(PkceAuthorizeCommand command, CancellationToken ct = default); + Task CompleteAsync(AuthFlowContext auth, PkceCompleteRequest request, CancellationToken ct = default); + Task RefreshAsync(HubFlowArtifact hub, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs new file mode 100644 index 00000000..0b13193c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshFlowService.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface IRefreshFlowService +{ + Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs new file mode 100644 index 00000000..16b55080 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IRefreshTokenRotationService.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface IRefreshTokenRotationService +{ + Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs new file mode 100644 index 00000000..6d53040f --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionApplicationService.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public interface ISessionApplicationService +{ + Task> GetUserChainsAsync(AccessContext context,UserKey userKey, PageRequest request, CancellationToken ct = default); + + Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + + Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default); + + Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default); + + Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default); + + Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default); + Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default); + Task LogoutOtherDevicesAsync(AccessContext context, UserKey userKey, SessionChainId currentChainId, CancellationToken ct = default); + Task LogoutAllDevicesAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); + + Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs new file mode 100644 index 00000000..fd6e472d --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionQueryService.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +// TenantId parameter only come from AuthFlowContext. +namespace CodeBeam.UltimateAuth.Server.Services; + +/// +/// Read-only session query API. +/// Used for validation, UI, monitoring, and diagnostics. +/// +public interface ISessionQueryService +{ + /// + /// Retrieves a specific session by id. + /// + Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default); + + /// + /// Retrieves all sessions belonging to a specific chain. + /// + Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default); + + /// + /// Retrieves all session chains for a user. + /// + Task> GetChainsByUserAsync(UserKey userKey, CancellationToken ct = default); + + /// + /// Resolves the chain id for a given session. + /// + Task ResolveChainIdAsync(AuthSessionId sessionId, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs new file mode 100644 index 00000000..665455ee --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/ISessionValidator.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +// This is a seperate service because validation runs only once before AuthFlowContext is created. +namespace CodeBeam.UltimateAuth.Server; + +public interface ISessionValidator +{ + /// + /// Validates a session for runtime authentication. + /// Hot path โ€“ must be fast and side-effect free. + /// + Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs new file mode 100644 index 00000000..4445c7e1 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/Abstractions/IUAuthFlowService.cs @@ -0,0 +1,34 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; + +namespace CodeBeam.UltimateAuth.Server.Services; + +/// +/// Handles authentication flows such as login, +/// logout, session refresh and reauthentication. +/// +public interface IUAuthFlowService +{ + Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default); + + Task LoginAsync(AuthFlowContext auth, AuthExecutionContext execution, LoginRequest request, CancellationToken ct); + + Task ExternalLoginAsync(ExternalLoginRequest request, CancellationToken ct = default); + + Task BeginMfaAsync(BeginMfaRequest 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); +} + +internal interface IUAuthInternalFlowService +{ + Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default); + Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs new file mode 100644 index 00000000..6081eb86 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/HubFlowService.cs @@ -0,0 +1,81 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Contracts; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class HubFlowService : IHubFlowService +{ + private readonly IAuthStore _authStore; + private readonly IClock _clock; + private readonly UAuthServerOptions _options; + + public HubFlowService( + IAuthStore authStore, + IClock clock, + IOptions options) + { + _authStore = authStore; + _clock = clock; + _options = options.Value; + } + + public async Task BeginLoginAsync(HubBeginRequest request, CancellationToken ct = default) + { + if (!string.IsNullOrWhiteSpace(request.PreviousHubSessionId)) + { + await _authStore.ConsumeAsync(new AuthArtifactKey(request.PreviousHubSessionId), ct); + } + + var hubSessionId = HubSessionId.New(); + + var payload = new HubFlowPayload(); + payload.Set("authorization_code", request.AuthorizationCode); + payload.Set("code_verifier", request.CodeVerifier); + + var artifact = new HubFlowArtifact( + hubSessionId, + HubFlowType.Login, + request.ClientProfile, + request.Tenant, + request.Device, + request.ReturnUrl, + payload, + _clock.UtcNow.Add(_options.Hub.FlowLifetime)); + + await _authStore.StoreAsync(new AuthArtifactKey(hubSessionId.Value), artifact, ct); + + return new HubSessionResult + { + HubSessionId = hubSessionId.Value + }; + } + + public async Task ContinuePkceAsync(string hubSessionId, string authorizationCode, string codeVerifier, CancellationToken ct = default) + { + var key = new AuthArtifactKey(hubSessionId); + + var artifact = await _authStore.GetAsync(key, ct) as HubFlowArtifact; + + if (artifact is null) + throw new InvalidOperationException("Hub session not found."); + + artifact.Payload.Set("authorization_code", authorizationCode); + artifact.Payload.Set("code_verifier", codeVerifier); + + artifact.ClearError(); + + await _authStore.StoreAsync(key, artifact, ct); + } + + public async Task ConsumeAsync(string hubSessionId, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(hubSessionId)) + return; + + await _authStore.ConsumeAsync(new AuthArtifactKey(hubSessionId), ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs new file mode 100644 index 00000000..c64bff3e --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/PkceService.cs @@ -0,0 +1,181 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Stores; +using Microsoft.Extensions.Options; +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class PkceService : IPkceService +{ + private readonly IAuthStore _authStore; + private readonly IPkceAuthorizationValidator _validator; + private readonly IUAuthFlowService _flow; + private readonly IClock _clock; + private readonly UAuthServerOptions _options; + + public PkceService(IAuthStore authStore, IPkceAuthorizationValidator validator, IUAuthFlowService flow, IClock clock, IOptions options) + { + _authStore = authStore; + _validator = validator; + _flow = flow; + _clock = clock; + _options = options.Value; + } + + public async Task AuthorizeAsync(PkceAuthorizeCommand command, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(command.CodeChallenge)) + throw new InvalidOperationException("code_challenge is required."); + + if (!string.Equals(command.ChallengeMethod, "S256", StringComparison.Ordinal)) + throw new InvalidOperationException("Only S256 supported."); + + var authorizationCode = AuthArtifactKey.New(); + + var snapshot = new PkceContextSnapshot( + clientProfile: command.ClientProfile, + tenant: command.Tenant, + redirectUri: command.RedirectUri, + device: command.Device + ); + + var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds); + + var artifact = new PkceAuthorizationArtifact( + authorizationCode: authorizationCode, + codeChallenge: command.CodeChallenge, + challengeMethod: PkceChallengeMethod.S256, + expiresAt: expiresAt, + context: snapshot + ); + + await _authStore.StoreAsync(authorizationCode, artifact, ct); + + return new PkceAuthorizeResponse + { + AuthorizationCode = authorizationCode.Value, + ExpiresIn = _options.Pkce.AuthorizationCodeLifetimeSeconds + }; + } + + public async Task CompleteAsync(AuthFlowContext auth, PkceCompleteRequest request, CancellationToken ct = default) + { + var key = new AuthArtifactKey(request.AuthorizationCode); + + var artifact = await _authStore.ConsumeAsync(key, ct) as PkceAuthorizationArtifact; + + if (artifact is null) + { + return new PkceCompleteResult + { + InvalidPkce = true + }; + } + + var validation = _validator.Validate( + artifact, + request.CodeVerifier, + new PkceContextSnapshot( + clientProfile: artifact.Context.ClientProfile, + tenant: artifact.Context.Tenant, + redirectUri: artifact.Context.RedirectUri, + device: artifact.Context.Device), + _clock.UtcNow); + + if (!validation.Success) + { + artifact.RegisterAttempt(); + + return new PkceCompleteResult + { + Success = false, + FailureReason = AuthFailureReason.InvalidCredentials + }; + } + + var loginRequest = new LoginRequest + { + Identifier = request.Identifier!, + Secret = request.Secret!, + RequestTokens = auth.AllowsTokenIssuance + }; + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = artifact.Context.ClientProfile, + Device = artifact.Context.Device + }; + + var result = await _flow.LoginAsync(auth, execution, loginRequest, ct); + + return new PkceCompleteResult + { + Success = result.IsSuccess, + FailureReason = result.FailureReason, + LoginResult = result + }; + } + + public async Task RefreshAsync(HubFlowArtifact hub, CancellationToken ct = default) + { + if (hub.Payload.TryGet("authorization_code", out var oldCode) && !string.IsNullOrWhiteSpace(oldCode)) + { + await _authStore.ConsumeAsync(new AuthArtifactKey(oldCode), ct); + } + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + var device = hub.Device; + var authorizationCode = AuthArtifactKey.New(); + + var snapshot = new PkceContextSnapshot( + clientProfile: hub.ClientProfile, + tenant: hub.Tenant, + redirectUri: hub.ReturnUrl, + device: device + ); + + var expiresAt = _clock.UtcNow.AddSeconds(_options.Pkce.AuthorizationCodeLifetimeSeconds); + + var artifact = new PkceAuthorizationArtifact( + authorizationCode, + challenge, + PkceChallengeMethod.S256, + expiresAt, + snapshot + ); + + await _authStore.StoreAsync(authorizationCode, artifact, ct); + + return new HubCredentials + { + AuthorizationCode = authorizationCode.Value, + CodeVerifier = verifier + }; + } + + private static string CreateVerifier() + { + return Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private static string CreateChallenge(string verifier) + { + using var sha256 = SHA256.Create(); + var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(verifier)); + + return Convert.ToBase64String(bytes) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs new file mode 100644 index 00000000..249de06c --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshFlowService.cs @@ -0,0 +1,201 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class RefreshFlowService : IRefreshFlowService +{ + private readonly ISessionValidator _sessionValidator; + private readonly ISessionTouchService _sessionRefresh; + private readonly IRefreshTokenRotationService _tokenRotation; + private readonly IClock _clock; + + public RefreshFlowService( + ISessionValidator sessionValidator, + ISessionTouchService sessionRefresh, + IRefreshTokenRotationService tokenRotation, + IClock clock) + { + _sessionValidator = sessionValidator; + _sessionRefresh = sessionRefresh; + _tokenRotation = tokenRotation; + _clock = clock; + } + + public async Task RefreshAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct = default) + { + return flow.EffectiveMode switch + { + UAuthMode.PureOpaque => + await HandleSessionOnlyAsync(flow, request, ct), + + UAuthMode.PureJwt => + await HandleTokenOnlyAsync(flow, request, ct), + + UAuthMode.Hybrid => + await HandleHybridAsync(flow, request, ct), + + UAuthMode.SemiHybrid => + await HandleSemiHybridAsync(flow, request, ct), + + _ => RefreshFlowResult.ReauthRequired() + }; + } + + private async Task HandleSessionOnlyAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (request.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + + var now = _clock.UtcNow; + + var validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = flow.Tenant, + SessionId = request.SessionId.Value, + Now = now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var touchPolicy = new SessionTouchPolicy + { + TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval + }; + + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); + + if (!refresh.IsSuccess || refresh.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + + return RefreshFlowResult.Success(refresh.DidTouch ? RefreshOutcome.Touched : RefreshOutcome.NoOp, refresh.SessionId); + } + + private async Task HandleTokenOnlyAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.RefreshToken)) + return RefreshFlowResult.ReauthRequired(); + + var now = _clock.UtcNow; + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = now, + Device = request.Device + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } + + private async Task HandleHybridAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (request.SessionId is null || string.IsNullOrWhiteSpace(request.RefreshToken)) + return RefreshFlowResult.ReauthRequired(); + + var now = _clock.UtcNow; + + var validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = flow.Tenant, + SessionId = request.SessionId.Value, + Now = now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = now, + Device = request.Device, + ExpectedSessionId = request.SessionId.Value + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + var touchPolicy = new SessionTouchPolicy + { + TouchInterval = flow.EffectiveOptions.Options.Session.TouchInterval + }; + + var refresh = await _sessionRefresh.RefreshAsync(validation, touchPolicy, request.TouchMode, now, ct); + + if (!refresh.IsSuccess || refresh.SessionId is null) + return RefreshFlowResult.ReauthRequired(); + + //await StoreRefreshTokenAsync(flow, rotation, request.Now, ct); + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + sessionId: refresh.SessionId, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } + + private async Task HandleSemiHybridAsync(AuthFlowContext flow, RefreshFlowRequest request, CancellationToken ct) + { + if (request.SessionId is null || string.IsNullOrWhiteSpace(request.RefreshToken)) + return RefreshFlowResult.ReauthRequired(); + + var now = _clock.UtcNow; + + var validation = await _sessionValidator.ValidateSessionAsync( + new SessionValidationContext + { + Tenant = flow.Tenant, + SessionId = request.SessionId.Value, + Now = now, + Device = request.Device + }, + ct); + + if (!validation.IsValid) + return RefreshFlowResult.ReauthRequired(); + + var rotation = await _tokenRotation.RotateAsync( + flow, + new RefreshTokenRotationContext + { + RefreshToken = request.RefreshToken!, + Now = now, + Device = request.Device, + ExpectedSessionId = request.SessionId.Value + }, + ct); + + if (!rotation.Result.IsSuccess) + return RefreshFlowResult.ReauthRequired(); + + return RefreshFlowResult.Success( + outcome: RefreshOutcome.Rotated, + sessionId: request.SessionId.Value, + accessToken: rotation.Result.AccessToken, + refreshToken: rotation.Result.RefreshToken); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs new file mode 100644 index 00000000..92f0e6d6 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/RefreshTokenRotationService.cs @@ -0,0 +1,118 @@ +๏ปฟ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.Auth; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public sealed class RefreshTokenRotationService : IRefreshTokenRotationService +{ + private readonly IRefreshTokenValidator _validator; + private readonly IRefreshTokenStoreFactory _storeFactory; + private readonly ITokenIssuer _tokenIssuer; + private readonly IClock _clock; + + public RefreshTokenRotationService(IRefreshTokenValidator validator, IRefreshTokenStoreFactory storeFactory, ITokenIssuer tokenIssuer, IClock clock) + { + _validator = validator; + _storeFactory = storeFactory; + _tokenIssuer = tokenIssuer; + _clock = clock; + } + + // TODO: Handle reuse detection and make flow knows situation, but don't make security branch. + public async Task RotateAsync(AuthFlowContext flow, RefreshTokenRotationContext context, CancellationToken ct = default) + { + var validation = await _validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = flow.Tenant, + RefreshToken = context.RefreshToken, + Now = context.Now, + Device = context.Device, + ExpectedSessionId = context.ExpectedSessionId + }, + ct); + + if (!validation.IsValid) + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; + + var store = _storeFactory.Create(validation.Tenant); + + if (validation.IsReuseDetected) + { + if (validation.ChainId is not null) + { + await store.RevokeByChainAsync(validation.ChainId.Value, context.Now, ct); + } + else if (validation.SessionId is not null) + { + await store.RevokeBySessionAsync(validation.SessionId.Value, context.Now, ct); + } + + return new RefreshTokenRotationExecution() { Result = RefreshTokenRotationResult.Failed() }; + } + + if (validation.UserKey is not UserKey userKey) + throw new UAuthValidationException("Validated refresh token does not contain a UserKey."); + + if (validation.SessionId is not AuthSessionId sessionId) + throw new UAuthValidationException("Validated refresh token does not contain a SessionId."); + + if (validation.TokenHash == null) + throw new UAuthValidationException("Validated refresh token does not contain a hashed token."); + + var tokenContext = new TokenIssuanceContext + { + Tenant = flow.OriginalOptions.MultiTenant.Enabled + ? validation.Tenant + : TenantKey.Single, + + UserKey = userKey, + SessionId = validation.SessionId, + ChainId = validation.ChainId + }; + + var accessToken = await _tokenIssuer.IssueAccessTokenAsync(flow, tokenContext, ct); + var refreshToken = await _tokenIssuer.IssueRefreshTokenAsync(flow, tokenContext, RefreshTokenPersistence.DoNotPersist, ct); + + if (refreshToken is null) + return new RefreshTokenRotationExecution + { + Result = RefreshTokenRotationResult.Failed() + }; + + // Never issue new refresh token before revoke old. Upperline doesn't persist token currently. + await store.ExecuteAsync(async ct2 => + { + await store.RevokeAsync(validation.TokenHash, context.Now, refreshToken.TokenHash, ct2); + + var stored = RefreshToken.Create( + tokenId: TokenId.New(), + tokenHash: refreshToken.TokenHash, + tenant: validation.Tenant, + userKey: userKey, + sessionId: sessionId, + chainId: validation.ChainId, + createdAt: _clock.UtcNow, + expiresAt: refreshToken.ExpiresAt + ); + + await store.StoreAsync(stored, ct2); + + }, ct); + + + return new RefreshTokenRotationExecution + { + Tenant = validation.Tenant, + UserKey = validation.UserKey, + SessionId = validation.SessionId, + ChainId = validation.ChainId, + Result = RefreshTokenRotationResult.Success(accessToken, refreshToken) + }; + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs new file mode 100644 index 00000000..4b62d6ab --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs @@ -0,0 +1,271 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class SessionApplicationService : ISessionApplicationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly ISessionStoreFactory _storeFactory; + private readonly IClock _clock; + + public SessionApplicationService(IAccessOrchestrator accessOrchestrator, ISessionStoreFactory storeFactory, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _storeFactory = storeFactory; + _clock = clock; + } + + public async Task> GetUserChainsAsync(AccessContext context, UserKey userKey, PageRequest request, CancellationToken ct = default) + { + var command = new AccessCommand>(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + request = request.Normalize(); + var chains = await store.GetChainsByUserAsync(userKey); + var actorChainId = context.ActorChainId; + + if (!string.IsNullOrWhiteSpace(request.SortBy)) + { + chains = request.SortBy switch + { + nameof(SessionChainSummary.ChainId) => request.Descending + ? chains.OrderByDescending(x => x.ChainId).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummary.CreatedAt) => request.Descending + ? chains.OrderByDescending(x => x.CreatedAt).ToList() + : chains.OrderBy(x => x.Version).ToList(), + + nameof(SessionChainSummary.LastSeenAt) => request.Descending + ? chains.OrderByDescending(x => x.LastSeenAt).ToList() + : chains.OrderBy(x => x.LastSeenAt).ToList(), + + nameof(SessionChainSummary.RevokedAt) => request.Descending + ? chains.OrderByDescending(x => x.RevokedAt).ToList() + : chains.OrderBy(x => x.RevokedAt).ToList(), + + nameof(SessionChainSummary.DeviceType) => request.Descending + ? chains.OrderByDescending(x => x.Device.DeviceType).ToList() + : chains.OrderBy(x => x.Device.DeviceType).ToList(), + + nameof(SessionChainSummary.OperatingSystem) => request.Descending + ? chains.OrderByDescending(x => x.Device.OperatingSystem).ToList() + : chains.OrderBy(x => x.Device.OperatingSystem).ToList(), + + nameof(SessionChainSummary.Platform) => request.Descending + ? chains.OrderByDescending(x => x.Device.Platform).ToList() + : chains.OrderBy(x => x.Device.Platform).ToList(), + + nameof(SessionChainSummary.Browser) => request.Descending + ? chains.OrderByDescending(x => x.Device.Browser).ToList() + : chains.OrderBy(x => x.Device.Browser).ToList(), + + nameof(SessionChainSummary.RotationCount) => request.Descending + ? chains.OrderByDescending(x => x.RotationCount).ToList() + : chains.OrderBy(x => x.RotationCount).ToList(), + + nameof(SessionChainSummary.TouchCount) => request.Descending + ? chains.OrderByDescending(x => x.TouchCount).ToList() + : chains.OrderBy(x => x.TouchCount).ToList(), + + _ => chains + }; + } + + var total = chains.Count; + + var pageItems = chains + .Skip((request.PageNumber - 1) * request.PageSize) + .Take(request.PageSize) + .Select(c => new SessionChainSummary + { + ChainId = c.ChainId, + DeviceType = c.Device.DeviceType, + OperatingSystem = c.Device.OperatingSystem, + Platform = c.Device.Platform, + Browser = c.Device.Browser, + CreatedAt = c.CreatedAt, + LastSeenAt = c.LastSeenAt, + RotationCount = c.RotationCount, + TouchCount = c.TouchCount, + IsRevoked = c.IsRevoked, + RevokedAt = c.RevokedAt, + ActiveSessionId = c.ActiveSessionId, + IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value, + State = c.State, + }) + .ToList(); + + return new PagedResult(pageItems, total, request.PageNumber, request.PageSize, request.SortBy, request.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserChainDetailAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var chain = await store.GetChainAsync(chainId) ?? throw new UAuthNotFoundException("chain_not_found"); + + if (chain.UserKey != userKey) + throw new UAuthValidationException("User conflict."); + + var sessions = await store.GetSessionsByChainAsync(chainId); + + return new SessionChainDetail + { + ChainId = chain.ChainId, + DeviceType = chain.Device.DeviceType, + OperatingSystem = chain.Device.OperatingSystem, + Platform = chain.Device.Platform, + Browser = chain.Device.Browser, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + State = chain.State, + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + IsRevoked = chain.IsRevoked, + RevokedAt = chain.RevokedAt, + ActiveSessionId = chain.ActiveSessionId, + + Sessions = sessions + .OrderByDescending(x => x.CreatedAt) + .Select(s => new SessionInfo( + s.SessionId, + s.CreatedAt, + s.ExpiresAt, + s.IsRevoked)) + .ToList() + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeUserSessionAsync(AccessContext context, UserKey userKey, AuthSessionId sessionId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + var session = await store.GetSessionAsync(sessionId) + ?? throw new InvalidOperationException("session_not_found"); + + if (session.UserKey != userKey) + throw new UnauthorizedAccessException(); + + var expected = session.Version; + var revoked = session.Revoke(now); + + await store.SaveSessionAsync(revoked, expected); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeUserChainAsync(AccessContext context, UserKey userKey, SessionChainId chainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var isCurrent = context.ActorChainId == chainId; + var store = _storeFactory.Create(context.ResourceTenant); + await store.RevokeChainCascadeAsync(chainId, _clock.UtcNow); + + return new RevokeResult + { + CurrentChain = isCurrent, + RootRevoked = false + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeOtherChainsAsync(AccessContext context, UserKey userKey, SessionChainId? currentChainId, CancellationToken ct = default) + { + await RevokeAllChainsAsync(context, userKey, currentChainId, ct); + } + + public async Task RevokeAllChainsAsync(AccessContext context, UserKey userKey, SessionChainId? exceptChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var chains = await store.GetChainsByUserAsync(userKey); + + foreach (var chain in chains) + { + if (exceptChainId.HasValue && chain.ChainId == exceptChainId.Value) + continue; + + await store.RevokeChainCascadeAsync(chain.ChainId, _clock.UtcNow); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutDeviceAsync(AccessContext context, SessionChainId currentChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var isCurrent = context.ActorChainId == currentChainId; + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.LogoutChainAsync(currentChainId, now, innerCt); + + return new RevokeResult + { + CurrentChain = isCurrent, + RootRevoked = false + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutOtherDevicesAsync(AccessContext context, UserKey userKey, SessionChainId currentChainId, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.RevokeOtherSessionsAsync(userKey, currentChainId, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task LogoutAllDevicesAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + var now = _clock.UtcNow; + + await store.RevokeAllSessionsAsync(userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task RevokeRootAsync(AccessContext context, UserKey userKey, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var store = _storeFactory.Create(context.ResourceTenant); + await store.RevokeRootCascadeAsync(userKey, _clock.UtcNow); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs new file mode 100644 index 00000000..95b91315 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthFlowService.cs @@ -0,0 +1,135 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class UAuthFlowService : IUAuthFlowService, IUAuthInternalFlowService +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAuthFlowContextFactory _authFlowContextFactory; + private readonly ILoginOrchestrator _loginOrchestrator; + private readonly IInternalLoginOrchestrator _internalLoginOrchestrator; + private readonly ISessionOrchestrator _orchestrator; + private readonly UAuthEventDispatcher _events; + private readonly IClock _clock; + + public UAuthFlowService( + IAuthFlowContextAccessor authFlow, + IAuthFlowContextFactory authFlowContextFactory, + ILoginOrchestrator loginOrchestrator, + IInternalLoginOrchestrator internalLoginOrchestrator, + ISessionOrchestrator orchestrator, + UAuthEventDispatcher events, + IClock clock) + { + _authFlow = authFlow; + _authFlowContextFactory = authFlowContextFactory; + _loginOrchestrator = loginOrchestrator; + _internalLoginOrchestrator = internalLoginOrchestrator; + _orchestrator = orchestrator; + _events = events; + _clock = clock; + } + + public Task LoginAsync(AuthFlowContext flow, LoginRequest request, CancellationToken ct = default) + { + return _loginOrchestrator.LoginAsync(flow, request, ct); + } + + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, CancellationToken ct = default) + { + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : await _authFlowContextFactory.RecreateWithClientProfileAsync(flow, (UAuthClientProfile)execution.EffectiveClientProfile, ct); + effectiveFlow = execution.Device is null + ? effectiveFlow + : await _authFlowContextFactory.RecreateWithDeviceAsync(effectiveFlow, execution.Device, ct); + return await _loginOrchestrator.LoginAsync(effectiveFlow, request, ct); + } + + public Task LoginAsync(AuthFlowContext flow, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default) + { + return _internalLoginOrchestrator.LoginAsync(flow, request, loginExecution, ct); + } + + public async Task LoginAsync(AuthFlowContext flow, AuthExecutionContext execution, LoginRequest request, LoginExecutionOptions loginExecution, CancellationToken ct = default) + { + var effectiveFlow = execution.EffectiveClientProfile is null + ? flow + : await _authFlowContextFactory.RecreateWithClientProfileAsync(flow, (UAuthClientProfile)execution.EffectiveClientProfile, ct); + effectiveFlow = execution.Device is null + ? effectiveFlow + : await _authFlowContextFactory.RecreateWithDeviceAsync(effectiveFlow, execution.Device, ct); + return await _internalLoginOrchestrator.LoginAsync(effectiveFlow, request, loginExecution, ct); + } + + public async Task LogoutAsync(LogoutRequest request, CancellationToken ct = default) + { + var authFlow = _authFlow.Current; + var now = _clock.UtcNow; + var authContext = authFlow.ToAuthContext(now); + + var revoked = await _orchestrator.ExecuteAsync(authContext, new RevokeSessionCommand(request.SessionId), ct); + + if (!revoked) + return; + + if (authFlow.UserKey is not UserKey uaKey) + return; + + await _events.DispatchAsync(new UserLoggedOutContext(authFlow.Tenant, uaKey, now, LogoutReason.Explicit, request.SessionId)); + } + + public async Task LogoutAllAsync(LogoutAllRequest request, CancellationToken ct = default) + { + var authFlow = _authFlow.Current; + var now = _clock.UtcNow; + + if (authFlow.Session is not SessionSecurityContext session) + throw new InvalidOperationException("LogoutAll requires an active session."); + + var authContext = authFlow.ToAuthContext(now); + SessionChainId? exceptChainId = null; + + if (request.ExceptCurrent) + { + exceptChainId = session.ChainId; + + if (exceptChainId is null) + throw new InvalidOperationException("Current session chain could not be resolved."); + } + + if (authFlow.UserKey is UserKey uaKey) + { + var command = new RevokeAllChainsCommand(uaKey, exceptChainId); + await _orchestrator.ExecuteAsync(authContext, command, 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(); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs new file mode 100644 index 00000000..ab4b6d4b --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthJwtValidator.cs @@ -0,0 +1,83 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using System.Security.Claims; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class UAuthJwtValidator : IJwtValidator +{ + private readonly JsonWebTokenHandler _jwtHandler; + private readonly TokenValidationParameters _jwtParameters; + private readonly IUserIdConverterResolver _converters; + + public UAuthJwtValidator(TokenValidationParameters jwtParameters, IUserIdConverterResolver converters) + { + _jwtHandler = new JsonWebTokenHandler(); + _jwtParameters = jwtParameters; + _converters = converters; + } + + public async Task> ValidateAsync(string token, CancellationToken ct = default) + { + var result = await _jwtHandler.ValidateTokenAsync(token, _jwtParameters); + + if (!result.IsValid) + { + return TokenValidationResult.Invalid(TokenType.Jwt, MapJwtError(result.Exception)); + } + + var jwt = (JsonWebToken)result.SecurityToken; + var claims = jwt.Claims.ToArray(); + + var converter = _converters.GetConverter(); + + var userIdString = jwt.GetClaim(ClaimTypes.NameIdentifier)?.Value ?? jwt.GetClaim("sub")?.Value; + if (string.IsNullOrWhiteSpace(userIdString)) + { + return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.MissingSubject); + } + + TUserId userId; + try + { + userId = converter.FromString(userIdString); + } + catch + { + return TokenValidationResult.Invalid(TokenType.Jwt, TokenInvalidReason.Malformed); + } + + var tenantId = jwt.GetClaim("tenant")?.Value ?? jwt.GetClaim("tid")?.Value; + AuthSessionId? sessionId = null; + var sid = jwt.GetClaim("sid")?.Value; + if (AuthSessionId.TryCreate(sid, out AuthSessionId ssid)) + { + sessionId = ssid; + } + + return TokenValidationResult.Valid( + type: TokenType.Jwt, + tenant: TenantKey.FromExternal(tenantId), + userId, + sessionId: sessionId, + claims: claims, + expiresAt: jwt.ValidTo); + } + + private static TokenInvalidReason MapJwtError(Exception? ex) + { + return ex switch + { + SecurityTokenExpiredException => TokenInvalidReason.Expired, + SecurityTokenInvalidSignatureException => TokenInvalidReason.SignatureInvalid, + SecurityTokenInvalidAudienceException => TokenInvalidReason.AudienceMismatch, + SecurityTokenInvalidIssuerException => TokenInvalidReason.IssuerMismatch, + _ => TokenInvalidReason.Invalid + }; + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs new file mode 100644 index 00000000..2e35a6cb --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionQueryService.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Server.Services; + +public sealed class UAuthSessionQueryService : ISessionQueryService +{ + private readonly ISessionStoreFactory _storeFactory; + private readonly IAuthFlowContextAccessor _authFlow; + + public UAuthSessionQueryService( + ISessionStoreFactory storeFactory, + IAuthFlowContextAccessor authFlow) + { + _storeFactory = storeFactory; + _authFlow = authFlow; + } + + public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + return CreateKernel().GetSessionAsync(sessionId); + } + + public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + return CreateKernel().GetSessionsByChainAsync(chainId); + } + + public Task> GetChainsByUserAsync(UserKey userKey, CancellationToken ct = default) + { + return CreateKernel().GetChainsByUserAsync(userKey); + } + + public Task ResolveChainIdAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + return CreateKernel().GetChainIdBySessionAsync(sessionId); + } + + private ISessionStore CreateKernel() + { + var tenantId = _authFlow.Current.Tenant; + return _storeFactory.Create(tenantId); + } + +} diff --git a/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs new file mode 100644 index 00000000..787e5281 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Services/UAuthSessionValidator.cs @@ -0,0 +1,85 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Server.Services; + +internal sealed class UAuthSessionValidator : ISessionValidator +{ + private readonly ISessionStoreFactory _storeFactory; + private readonly IUserClaimsProvider _claimsProvider; + private readonly UAuthServerOptions _options; + + public UAuthSessionValidator(ISessionStoreFactory storeFactory, IUserClaimsProvider claimsProvider, IOptions options) + { + _storeFactory = storeFactory; + _claimsProvider = claimsProvider; + _options = options.Value; + } + + // TODO: Improve Device binding + // Validate runs before AuthFlowContext is set, do not call _authFlow here. + + // TODO: Add GetSessionAggregate store method to 1 call instead of 3 calls of root chain session + public async Task ValidateSessionAsync(SessionValidationContext context, CancellationToken ct = default) + { + var kernel = _storeFactory.Create(context.Tenant); + var session = await kernel.GetSessionAsync(context.SessionId); + + if (session is null) + return SessionValidationResult.Invalid(SessionState.NotFound, sessionId: context.SessionId); + + var state = session.GetState(context.Now); + if (state != SessionState.Active) + return SessionValidationResult.Invalid(state, session.UserKey, session.SessionId, session.ChainId); + + var chain = await kernel.GetChainAsync(session.ChainId); + if (chain is null || chain.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked, session.UserKey, session.SessionId, session.ChainId); + + var chainState = chain.GetState(context.Now, _options.Session.IdleTimeout); + if (chainState != SessionState.Active) + return SessionValidationResult.Invalid(chainState, chain.UserKey, session.SessionId, chain.ChainId); + + //if (chain.ActiveSessionId != session.SessionId) + // return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + if (chain.ChainId != session.ChainId) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + if (chain.Tenant != context.Tenant) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId); + + var root = await kernel.GetRootByUserAsync(session.UserKey); + if (root is null || root.IsRevoked) + return SessionValidationResult.Invalid(SessionState.Revoked, chain.UserKey, session.SessionId, chain.ChainId, root?.RootId); + + if (chain.RootId != root.RootId) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + + if (session.SecurityVersionAtCreation != root.SecurityVersion) + return SessionValidationResult.Invalid(SessionState.SecurityMismatch, session.UserKey, session.SessionId, session.ChainId, root.RootId); + + if (chain.Device.HasDeviceId && context.Device.HasDeviceId) + { + if (!Equals(chain.Device.DeviceId, context.Device.DeviceId)) + { + if (_options.Session.DeviceMismatchBehavior == DeviceMismatchBehavior.Reject) + return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + + //if (_options.Session.DeviceMismatchBehavior == AllowAndRebind) + } + } + //else + //{ + // // Add SessionValidatorOrigin to seperate UserRequest or background task + // return SessionValidationResult.Invalid(SessionState.DeviceMismatch, chain.UserKey, session.SessionId, chain.ChainId, root.RootId); + //} + + var claims = await _claimsProvider.GetClaimsAsync(context.Tenant, session.UserKey, ct); + return SessionValidationResult.Active(context.Tenant, session.UserKey, session.SessionId, session.ChainId, root.RootId, claims, session.CreatedAt, chain.Device.DeviceId); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/Session/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Session/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Session/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/.gitkeep b/src/CodeBeam.UltimateAuth.Server/Stores/.gitkeep deleted file mode 100644 index 5f282702..00000000 --- a/src/CodeBeam.UltimateAuth.Server/Stores/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -๏ปฟ \ No newline at end of file diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs new file mode 100644 index 00000000..1c80abfc --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/AuthArtifactKey.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Server.Stores; + +public sealed record AuthArtifactKey(string Value) +{ + public static AuthArtifactKey New() => new(Guid.NewGuid().ToString("N")); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs new file mode 100644 index 00000000..88dddea9 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/IAuthStore.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Server.Stores; + +public interface IAuthStore +{ + Task StoreAsync(AuthArtifactKey key, AuthArtifact artifact, CancellationToken cancellationToken = default); + + Task GetAsync(AuthArtifactKey key, CancellationToken cancellationToken = default); + + /// + /// Atomically gets and removes the artifact. + /// This MUST be consume-once. + /// + Task ConsumeAsync(AuthArtifactKey key, CancellationToken cancellationToken = default); +} diff --git a/src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs new file mode 100644 index 00000000..7b84b894 --- /dev/null +++ b/src/CodeBeam.UltimateAuth.Server/Stores/Auth/InMemoryAuthStore.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Server.Stores; + +internal sealed class InMemoryAuthStore : IAuthStore +{ + private sealed record Entry(AuthArtifact Artifact); + + private readonly ConcurrentDictionary _store = new(); + + public Task StoreAsync(AuthArtifactKey key, AuthArtifact artifact, CancellationToken cancellationToken = default) + { + _store[key.Value] = new Entry(artifact); + return Task.CompletedTask; + } + + public Task GetAsync(AuthArtifactKey key, CancellationToken cancellationToken = default) + { + if (!_store.TryGetValue(key.Value, out var entry)) + return Task.FromResult(null); + + if (entry.Artifact.IsExpired(DateTimeOffset.UtcNow)) + { + _store.TryRemove(key.Value, out _); + return Task.FromResult(null); + } + + return Task.FromResult(entry.Artifact); + } + + public Task ConsumeAsync(AuthArtifactKey key, CancellationToken cancellationToken = default) + { + if (!_store.TryRemove(key.Value, out var entry)) + return Task.FromResult(null); + + if (entry.Artifact.IsExpired(DateTimeOffset.UtcNow)) + return Task.FromResult(null); + + return Task.FromResult(entry.Artifact); + } +} diff --git a/src/CodeBeam.UltimateAuth.Server/logo.png b/src/CodeBeam.UltimateAuth.Server/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/CodeBeam.UltimateAuth.Server/logo.png differ diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/AssemblyVisibility.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj new file mode 100644 index 00000000..f4246bad --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore + + + Entity Framework Core authentication persistence for UltimateAuth. + Provides durable storage for authentication state, sessions and identity resolution. + + + authentication;session;identity;efcore;database;auth-framework + logo.png + README.md + + + + + + + + + + + + + diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs new file mode 100644 index 00000000..c7c19cc1 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationDbContext.cs @@ -0,0 +1,19 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +public sealed class UAuthAuthenticationDbContext : DbContext +{ + public DbSet AuthenticationSecurityStates => Set(); + + + public UAuthAuthenticationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UAuthAuthenticationModelBuilder.Configure(modelBuilder); + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs new file mode 100644 index 00000000..4689db06 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Data/UAuthAuthenticationModelBuilder.cs @@ -0,0 +1,75 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +public static class UAuthAuthenticationModelBuilder +{ + public static void Configure(ModelBuilder b) + { + ConfigureAuthenticationSecurityState(b); + } + + private static void ConfigureAuthenticationSecurityState(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_Authentication"); + + e.HasKey(x => x.Id); + + e.Property(x => x.SecurityVersion) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Scope) + .IsRequired(); + + e.Property(x => x.CredentialType); + + e.Property(x => x.FailedAttempts) + .IsRequired(); + + e.Property(x => x.LastFailedAt) + .HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.LockedUntil) + .HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.RequiresReauthentication) + .IsRequired(); + + e.Property(x => x.ResetRequestedAt) + .HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.ResetExpiresAt) + .HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.ResetConsumedAt) + .HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.ResetTokenHash) + .HasMaxLength(512); + + e.Property(x => x.ResetAttempts) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey, x.Scope, x.CredentialType }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.LockedUntil }); + e.HasIndex(x => new { x.Tenant, x.ResetRequestedAt }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.Scope }); + }); + } +} \ No newline at end of file diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..1a9db677 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthAuthenticationEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext + { + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + return services; + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Mappers/AuthenticationSecurityStateMapper.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Mappers/AuthenticationSecurityStateMapper.cs new file mode 100644 index 00000000..1de60842 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Mappers/AuthenticationSecurityStateMapper.cs @@ -0,0 +1,65 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Security; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +internal static class AuthenticationSecurityStateMapper +{ + public static AuthenticationSecurityState ToDomain(AuthenticationSecurityStateProjection p) + { + return AuthenticationSecurityState.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.Scope, + p.CredentialType, + p.FailedAttempts, + p.LastFailedAt, + p.LockedUntil, + p.RequiresReauthentication, + p.ResetRequestedAt, + p.ResetExpiresAt, + p.ResetConsumedAt, + p.ResetTokenHash, + p.ResetAttempts, + p.SecurityVersion); + } + + public static AuthenticationSecurityStateProjection ToProjection(AuthenticationSecurityState d) + { + return new AuthenticationSecurityStateProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + Scope = d.Scope, + CredentialType = d.CredentialType, + FailedAttempts = d.FailedAttempts, + LastFailedAt = d.LastFailedAt, + LockedUntil = d.LockedUntil, + RequiresReauthentication = d.RequiresReauthentication, + ResetRequestedAt = d.ResetRequestedAt, + ResetExpiresAt = d.ResetExpiresAt, + ResetConsumedAt = d.ResetConsumedAt, + ResetTokenHash = d.ResetTokenHash, + ResetAttempts = d.ResetAttempts, + SecurityVersion = d.SecurityVersion + }; + } + + public static void UpdateProjection(AuthenticationSecurityState d, AuthenticationSecurityStateProjection p) + { + p.FailedAttempts = d.FailedAttempts; + p.LastFailedAt = d.LastFailedAt; + p.LockedUntil = d.LockedUntil; + p.RequiresReauthentication = d.RequiresReauthentication; + + p.ResetRequestedAt = d.ResetRequestedAt; + p.ResetExpiresAt = d.ResetExpiresAt; + p.ResetConsumedAt = d.ResetConsumedAt; + p.ResetTokenHash = d.ResetTokenHash; + p.ResetAttempts = d.ResetAttempts; + + p.SecurityVersion = d.SecurityVersion; + } +} + diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs new file mode 100644 index 00000000..266bf15a --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Projections/AuthenticationSecutiryStateProjection.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +public sealed class AuthenticationSecurityStateProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } + + public AuthenticationSecurityScope Scope { get; set; } + public CredentialType? CredentialType { get; set; } + + public int FailedAttempts { get; set; } + public DateTimeOffset? LastFailedAt { get; set; } + public DateTimeOffset? LockedUntil { get; set; } + public bool RequiresReauthentication { get; set; } + + public DateTimeOffset? ResetRequestedAt { get; set; } + public DateTimeOffset? ResetExpiresAt { get; set; } + public DateTimeOffset? ResetConsumedAt { get; set; } + public string? ResetTokenHash { get; set; } + public int ResetAttempts { get; set; } + + public long SecurityVersion { get; set; } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/README.md b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/README.md new file mode 100644 index 00000000..4adc14a1 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/README.md @@ -0,0 +1,32 @@ +๏ปฟ# UltimateAuth Authentication EntityFrameworkCore + +Entity Framework Core persistence for UltimateAuth authentication. + +## Purpose + +Provides durable storage for: + +- Authentication state +- Session lifecycle +- Identity resolution + +## Features + +- Persistent authentication state +- Scalable session storage +- Production-ready infrastructure + +## Notes + +- Requires EF Core setup +- Migrations handled by application + +## When to use + +- Production environments +- Distributed systems + +## Alternatives + +- CodeBeam.UltimateAuth.Authentication.InMemory +- Custom packages \ No newline at end of file diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..fe12bfcd --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStore.cs @@ -0,0 +1,84 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +internal sealed class EfCoreAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreAuthenticationSecurityStateStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task GetAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + var entity = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.Scope == scope && + x.CredentialType == credentialType, + ct); + + return entity is null + ? null + : AuthenticationSecurityStateMapper.ToDomain(entity); + } + + public async Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + var entity = AuthenticationSecurityStateMapper.ToProjection(state); + + DbSet.Add(entity); + + await _db.SaveChangesAsync(ct); + } + + public async Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) + { + var entity = await DbSet + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.Id == state.Id, + ct); + + if (entity is null) + throw new UAuthNotFoundException("security_state_not_found"); + + if (entity.SecurityVersion != expectedVersion) + throw new UAuthConflictException("security_state_version_conflict"); + + AuthenticationSecurityStateMapper.UpdateProjection(state, entity); + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(UserKey userKey, AuthenticationSecurityScope scope, CredentialType? credentialType, CancellationToken ct = default) + { + var entity = await DbSet + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.Scope == scope && + x.CredentialType == credentialType, + ct); + + if (entity is null) + return; + + DbSet.Remove(entity); + + await _db.SaveChangesAsync(ct); + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs new file mode 100644 index 00000000..5f897cf7 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/Stores/EfCoreAuthenticationSecurityStateStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; + +internal sealed class EfCoreAuthenticationSecurityStateStoreFactory : IAuthenticationSecurityStateStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreAuthenticationSecurityStateStoreFactory(TDbContext db) + { + _db = db; + } + + public IAuthenticationSecurityStateStore Create(TenantKey tenant) + { + return new EfCoreAuthenticationSecurityStateStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/logo.png b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authentication/CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore/logo.png differ diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj new file mode 100644 index 00000000..1129e77a --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/CodeBeam.UltimateAuth.Authentication.InMemory.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authentication.InMemory + + + In-memory authentication persistence for UltimateAuth. + Provides lightweight storage for authentication state such as sessions and authentication context. Suitable for development and testing. + + + authentication;session;identity;inmemory;auth-framework + logo.png + README.md + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs new file mode 100644 index 00000000..6a191aeb --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStore.cs @@ -0,0 +1,107 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +internal sealed class InMemoryAuthenticationSecurityStateStore : IAuthenticationSecurityStateStore +{ + private readonly TenantKey _tenant; + + private readonly ConcurrentDictionary _byId = new(); + private readonly ConcurrentDictionary<(UserKey, AuthenticationSecurityScope, CredentialType?), Guid> _index = new(); + + public InMemoryAuthenticationSecurityStateStore(TenantContext tenant) + { + _tenant = tenant.Tenant; + } + + public Task GetAsync( + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (userKey, scope, credentialType); + + if (_index.TryGetValue(key, out var id) && + _byId.TryGetValue(id, out var state)) + { + return Task.FromResult(state.Snapshot()); + } + + return Task.FromResult(null); + } + + public Task AddAsync(AuthenticationSecurityState state, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (state.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + var key = (state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryAdd(key, state.Id)) + throw new UAuthConflictException("security_state_already_exists"); + + var snapshot = state.Snapshot(); + + if (!_byId.TryAdd(state.Id, snapshot)) + { + _index.TryRemove(key, out _); + throw new UAuthConflictException("security_state_add_failed"); + } + + return Task.CompletedTask; + } + + public Task UpdateAsync(AuthenticationSecurityState state, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (state.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + var key = (state.UserKey, state.Scope, state.CredentialType); + + if (!_index.TryGetValue(key, out var id) || id != state.Id) + throw new UAuthConflictException("security_state_index_corrupted"); + + if (!_byId.TryGetValue(state.Id, out var current)) + throw new UAuthNotFoundException("security_state_not_found"); + + if (current.SecurityVersion != expectedVersion) + throw new UAuthConflictException("security_state_version_conflict"); + + var next = state.Snapshot(); + + if (!_byId.TryUpdate(state.Id, next, current)) + throw new UAuthConflictException("security_state_update_conflict"); + + return Task.CompletedTask; + } + + public Task DeleteAsync( + UserKey userKey, + AuthenticationSecurityScope scope, + CredentialType? credentialType, + CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = (userKey, scope, credentialType); + + if (!_index.TryRemove(key, out var id)) + return Task.CompletedTask; + + _byId.TryRemove(id, out _); + + return Task.CompletedTask; + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs new file mode 100644 index 00000000..dfc34430 --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/InMemoryAuthenticationSecurityStateStoreFactory.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory; + +internal sealed class InMemoryAuthenticationSecurityStateStoreFactory : IAuthenticationSecurityStateStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IAuthenticationSecurityStateStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => new InMemoryAuthenticationSecurityStateStore(new TenantContext(t))); + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/README.md b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/README.md new file mode 100644 index 00000000..952003cc --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Authentication InMemory + +In-memory authentication persistence for UltimateAuth. + +## Purpose + +Provides lightweight storage for: + +- Authentication state +- Session validation context +- Identity resolution data + +## When to use + +- Development +- Testing +- Local environments + +## โš ๏ธ Not for production + +All authentication state is stored in memory and will be lost when the application restarts. + +## Notes + +- No external dependencies +- Zero configuration required + +## Use instead (production) + +- CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore +- Custom authentication persistence \ No newline at end of file diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..cdab2eec --- /dev/null +++ b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authentication.InMemory.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthAuthenticationInMemory(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/logo.png b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authentication/CodeBeam.UltimateAuth.Authentication.InMemory/logo.png differ diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj new file mode 100644 index 00000000..0d5428ad --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/CodeBeam.UltimateAuth.Authorization.Contracts.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authorization.Contracts + + + Shared contracts and cross-boundary types for UltimateAuth Authorization module. + Includes role identifiers, permission models and shared authorization data structures. + Does NOT include domain logic or persistence. + + + authentication;authorization;roles;permissions;contracts;shared;auth-framework + logo.png + README.md + + + + + + + + + + + + \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs new file mode 100644 index 00000000..822da088 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/Permission.cs @@ -0,0 +1,21 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct Permission(string Value) +{ + public static Permission From(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("permission_required"); + + return new Permission(value.Trim().ToLowerInvariant()); + } + + public static readonly Permission Wildcard = new("*"); + + public bool IsWildcard => Value == "*"; + public bool IsPrefix => Value.EndsWith(".*"); + + public override string ToString() => Value; + + public static implicit operator string(Permission p) => p.Value; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs new file mode 100644 index 00000000..bc051dfb --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Domain/RoleId.cs @@ -0,0 +1,24 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public readonly record struct RoleId(Guid Value) : IParsable +{ + public static RoleId New() => new(Guid.NewGuid()); + + public static RoleId From(Guid guid) => new(guid); + + public static RoleId Parse(string s, IFormatProvider? provider) => new(Guid.Parse(s)); + + public static bool TryParse(string? s, IFormatProvider? provider, out RoleId result) + { + if (Guid.TryParse(s, out var guid)) + { + result = new RoleId(guid); + return true; + } + + result = default; + return false; + } + + public override string ToString() => Value.ToString(); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs new file mode 100644 index 00000000..67c61d8e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/RoleInfo.cs @@ -0,0 +1,13 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RoleInfo +{ + public RoleId Id { get; init; } + public required string Name { get; init; } + + public IReadOnlyCollection Permissions { get; init; } = Array.Empty(); + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? UpdatedAt { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs new file mode 100644 index 00000000..8dcca75b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Dtos/UserRoleInfo.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public class UserRoleInfo +{ + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } + public RoleId RoleId { get; init; } + public string Name { get; set; } = default!; + public DateTimeOffset AssignedAt { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs new file mode 100644 index 00000000..e1214ce1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/CompiledPermissionSet.cs @@ -0,0 +1,47 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class CompiledPermissionSet +{ + private readonly HashSet _exact = new(); + private readonly HashSet _prefix = new(); + private readonly bool _hasWildcard; + + public CompiledPermissionSet(IEnumerable permissions) + { + foreach (var p in permissions) + { + var value = p.Value; + + if (value == "*") + { + _hasWildcard = true; + continue; + } + + if (value.EndsWith(".*")) + { + _prefix.Add(value[..^2]); + continue; + } + + _exact.Add(value); + } + } + + public bool IsAllowed(string action) + { + if (_hasWildcard) + return true; + + if (_exact.Contains(action)) + return true; + + foreach (var prefix in _prefix) + { + if (action.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs new file mode 100644 index 00000000..c076dbd4 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionExpander.cs @@ -0,0 +1,29 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class PermissionExpander +{ + public static IReadOnlyCollection Expand(IEnumerable stored, IEnumerable catalog) + { + var result = new HashSet(); + + foreach (var perm in stored) + { + if (perm.IsWildcard) + { + result.UnionWith(catalog); + continue; + } + + if (perm.IsPrefix) + { + var prefix = perm.Value[..^2]; + result.UnionWith(catalog.Where(x => x.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase))); + continue; + } + + result.Add(perm.Value); + } + + return result.Select(Permission.From).ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs new file mode 100644 index 00000000..a9f64276 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/PermissionNormalizer.cs @@ -0,0 +1,43 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class PermissionNormalizer +{ + public static IReadOnlyCollection Normalize(IEnumerable permissions, IEnumerable catalog) + { + var selected = new HashSet(permissions.Select(p => p.Value), StringComparer.OrdinalIgnoreCase); + + if (selected.Contains("*")) + return new[] { Permission.Wildcard }; + + if (selected.Count == catalog.Count() && catalog.All(selected.Contains)) + { + return new[] { Permission.Wildcard }; + } + + var result = new HashSet(); + + var catalogGroups = catalog.GroupBy(p => p.Split('.')[0]); + + foreach (var group in catalogGroups) + { + var prefix = group.Key; + + var allPermissions = group.ToHashSet(StringComparer.OrdinalIgnoreCase); + + var selectedInGroup = selected + .Where(p => p.StartsWith(prefix + ".", StringComparison.OrdinalIgnoreCase)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (selectedInGroup.SetEquals(allPermissions)) + { + result.Add(prefix + ".*"); + } + else + { + result.UnionWith(selectedInGroup); + } + } + + return result.Select(Permission.From).ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs new file mode 100644 index 00000000..b3a0b59a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Infrastructure/UAuthPermissionCatalog.cs @@ -0,0 +1,40 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public static class UAuthPermissionCatalog +{ + public static IReadOnlyList GetAll() + { + var result = new List(); + Collect(typeof(UAuthActions), result); + return result; + } + + public static IReadOnlyList GetAdminPermissions() + { + return GetAll() + .Where(x => x.EndsWith(".admin", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + private static void Collect(Type type, List result) + { + foreach (var field in type.GetFields( + BindingFlags.Public | + BindingFlags.Static | + BindingFlags.FlattenHierarchy)) + { + if (field.IsLiteral && field.FieldType == typeof(string)) + { + result.Add((string)field.GetValue(null)!); + } + } + + foreach (var nested in type.GetNestedTypes()) + { + Collect(nested, result); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/README.md b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/README.md new file mode 100644 index 00000000..9ed9d4ca --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/README.md @@ -0,0 +1,32 @@ +๏ปฟ# UltimateAuth Authorization Contracts + +Shared contracts and cross-boundary models for the Authorization module. + +## Purpose + +This package contains: + +- Role identifiers +- Permission models +- Authorization-related DTOs + +## Does NOT include + +- Domain logic +- Persistence +- Policy enforcement logic + +## Usage + +Used by: + +- Server implementations +- Client SDKs +- Custom authorization providers + +โš ๏ธ Usually installed transitively via: + +- CodeBeam.UltimateAuth.Server +- CodeBeam.UltimateAuth.Client + +No need to install it directly in most scenarios. \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs new file mode 100644 index 00000000..dd258fbb --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AssignRoleRequest.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record AssignRoleRequest +{ + public required UserKey UserKey { get; init; } + public required string RoleName { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs new file mode 100644 index 00000000..1046ce61 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationCheckRequest.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record AuthorizationCheckRequest +{ + public required string Action { get; init; } + public 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 new file mode 100644 index 00000000..4e3f0f33 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/AuthorizationRequest.cs @@ -0,0 +1,24 @@ +๏ปฟ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.Contracts/Requests/CreateRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs new file mode 100644 index 00000000..e1edb5d8 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/CreateRoleRequest.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record CreateRoleRequest +{ + public required string Name { get; init; } + public IEnumerable? Permissions { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs new file mode 100644 index 00000000..810be03f --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/DeleteRoleRequest.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record DeleteRoleRequest +{ + public required RoleId Id { get; init; } + public DeleteMode Mode { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RemoveRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RemoveRoleRequest.cs new file mode 100644 index 00000000..37bb4a22 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RemoveRoleRequest.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RemoveRoleRequest +{ + public required UserKey UserKey { get; init; } + public required string RoleName { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs new file mode 100644 index 00000000..b30df8db --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RenameRoleRequest.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RenameRoleRequest +{ + public required RoleId Id { get; init; } + public required string Name { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RoleQuery.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RoleQuery.cs new file mode 100644 index 00000000..e0262ebb --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/RoleQuery.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record RoleQuery : PageRequest +{ + public string? Search { get; set; } + public bool IncludeDeleted { get; set; } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetRolePermissionsRequest.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetRolePermissionsRequest.cs new file mode 100644 index 00000000..2286ecd9 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Requests/SetRolePermissionsRequest.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record SetRolePermissionsRequest +{ + public required RoleId RoleId { get; init; } + public IEnumerable Permissions { get; init; } = []; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs new file mode 100644 index 00000000..1c3d78d0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/AuthorizationResult.cs @@ -0,0 +1,25 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record AuthorizationResult +{ + public required bool IsAllowed { get; init; } + + /// + /// Indicates whether re-authentication is required. + /// + public bool RequiresReauthentication { get; init; } + + /// + /// Optional reason code for denial. + /// + public string? DenyReason { get; init; } + + public static AuthorizationResult Allow() + => new() { IsAllowed = true }; + + public static AuthorizationResult Deny(string? reason = null) + => new() { IsAllowed = false, DenyReason = reason }; + + public static AuthorizationResult ReauthRequired() + => new() { IsAllowed = false, RequiresReauthentication = true }; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs new file mode 100644 index 00000000..ce6c06b1 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/DeleteRoleResult.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed class DeleteRoleResult +{ + public RoleId RoleId { get; init; } + + public int RemovedAssignments { get; init; } + + public DeleteMode Mode { get; init; } + + public DateTimeOffset DeletedAt { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs new file mode 100644 index 00000000..1acc9da8 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/Responses/UserRolesResponse.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization.Contracts; + +public sealed record UserRolesResponse +{ + public required UserKey UserKey { get; init; } + public required PagedResult Roles { get; init; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/logo.png b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authorization/CodeBeam.UltimateAuth.Authorization.Contracts/logo.png differ diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/AssemblyVisibility.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj new file mode 100644 index 00000000..ecb06423 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.csproj @@ -0,0 +1,30 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore + + + Entity Framework Core persistence implementation for UltimateAuth Authorization module. + Provides durable storage for roles, claims and authorization data. + + + authentication;authorization;roles;claims;efcore;database;auth-framework + logo.png + README.md + + + + + + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs new file mode 100644 index 00000000..187e1d4d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationDbContext.cs @@ -0,0 +1,20 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +public sealed class UAuthAuthorizationDbContext : DbContext +{ + public DbSet Roles => Set(); + public DbSet RolePermissions => Set(); + public DbSet UserRoles => Set(); + + public UAuthAuthorizationDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UAuthAuthorizationModelBuilder.Configure(modelBuilder); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationModelBuilder.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationModelBuilder.cs new file mode 100644 index 00000000..1d50fdbb --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Data/UAuthAuthorizationModelBuilder.cs @@ -0,0 +1,109 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +public static class UAuthAuthorizationModelBuilder +{ + public static void Configure(ModelBuilder b) + { + ConfigureRoles(b); + ConfigureRolePermissions(b); + ConfigureUserRoles(b); + } + + private static void ConfigureRoles(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_Roles"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Id) + .HasConversion(v => v.Value, v => RoleId.From(v)) + .IsRequired(); + + e.Property(x => x.Name) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.NormalizedName) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.NormalizedName }).IsUnique(); + }); + } + + private static void ConfigureRolePermissions(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_RolePermissions"); + + e.HasKey(x => new { x.Tenant, x.RoleId, x.Permission }); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.RoleId) + .HasConversion(v => v.Value, v => RoleId.From(v)) + .IsRequired(); + + e.Property(x => x.Permission) + .HasMaxLength(256) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.RoleId }); + e.HasIndex(x => new { x.Tenant, x.Permission }); + }); + } + + private static void ConfigureUserRoles(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_UserRoles"); + + e.HasKey(x => new { x.Tenant, x.UserKey, x.RoleId }); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.RoleId) + .HasConversion(v => v.Value, v => RoleId.From(v)) + .IsRequired(); + + e.Property(x => x.AssignedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.RoleId }); + }); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..8ed556c8 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthAuthorizationEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext + { + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + services.AddScoped>(); + return services; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RoleMapper.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RoleMapper.cs new file mode 100644 index 00000000..6be91dfa --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RoleMapper.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal static class RoleMapper +{ + public static Role ToDomain(RoleProjection projection, IEnumerable permissionEntities) + { + var permissions = permissionEntities.Select(RolePermissionMapper.ToDomain); + + return Role.FromProjection( + projection.Id, + projection.Tenant, + projection.Name, + permissions, + projection.CreatedAt, + projection.UpdatedAt, + projection.DeletedAt, + projection.Version); + } + + public static RoleProjection ToProjection(Role role) + { + return new RoleProjection + { + Id = role.Id, + Tenant = role.Tenant, + Name = role.Name, + NormalizedName = role.NormalizedName, + CreatedAt = role.CreatedAt, + UpdatedAt = role.UpdatedAt, + DeletedAt = role.DeletedAt, + Version = role.Version + }; + } + + public static void UpdateProjection(Role role, RoleProjection entity) + { + entity.Name = role.Name; + entity.NormalizedName = role.NormalizedName; + entity.UpdatedAt = role.UpdatedAt; + } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs new file mode 100644 index 00000000..9c640976 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/RolePermissionMapper.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal static class RolePermissionMapper +{ + public static RolePermissionProjection ToProjection(TenantKey tenant, RoleId roleId, Permission permission) + { + return new RolePermissionProjection + { + Tenant = tenant, + RoleId = roleId, + Permission = permission.Value + }; + } + + public static Permission ToDomain(RolePermissionProjection projection) + { + return Permission.From(projection.Permission); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/UserRoleMapper.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/UserRoleMapper.cs new file mode 100644 index 00000000..80855a54 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Mappers/UserRoleMapper.cs @@ -0,0 +1,26 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal static class UserRoleMapper +{ + public static UserRole ToDomain(UserRoleProjection projection) + { + return new UserRole + { + Tenant = projection.Tenant, + UserKey = projection.UserKey, + RoleId = projection.RoleId, + AssignedAt = projection.AssignedAt + }; + } + + public static UserRoleProjection ToProjection(UserRole role) + { + return new UserRoleProjection + { + Tenant = role.Tenant, + UserKey = role.UserKey, + RoleId = role.RoleId, + AssignedAt = role.AssignedAt + }; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs new file mode 100644 index 00000000..bc12b9a0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RolePermissionProjection.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +public sealed class RolePermissionProjection +{ + public TenantKey Tenant { get; set; } + + public RoleId RoleId { get; set; } + + public string Permission { get; set; } = default!; +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs new file mode 100644 index 00000000..e4e5e80e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/RoleProjection.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +public sealed class RoleProjection +{ + public RoleId Id { get; set; } + + public TenantKey Tenant { get; set; } + + public string Name { get; set; } = default!; + + public string NormalizedName { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/UserRoleProjection.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/UserRoleProjection.cs new file mode 100644 index 00000000..22f956e2 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Projections/UserRoleProjection.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +public sealed class UserRoleProjection +{ + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } + + public RoleId RoleId { get; set; } + + public DateTimeOffset AssignedAt { get; set; } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/README.md b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/README.md new file mode 100644 index 00000000..4b14900b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Authorization EntityFrameworkCore + +Entity Framework Core persistence implementation for the UltimateAuth Authorization module. + +## Purpose + +Provides durable storage for: + +- Roles +- Claims +- Authorization rules + +## Features + +- Persistent role storage +- Claims-based authorization support +- Scalable architecture + +## Notes + +- Requires EF Core setup +- Migrations handled by application + +## When to use + +- Production environments + +## Alternatives + +- CodeBeam.UltimateAuth.Authorization.InMemory +- Custom packages \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs new file mode 100644 index 00000000..f5151afa --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStore.cs @@ -0,0 +1,273 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class EfCoreRoleStore : IRoleStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreRoleStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSetRole => _db.Set(); + private DbSet DbSetPermission => _db.Set(); + + public async Task ExistsAsync(RoleKey key, CancellationToken ct = default) + { + return await DbSetRole + .AnyAsync(x => + x.Tenant == _tenant && + x.Id == key.RoleId, + ct); + } + + public async Task AddAsync(Role role, CancellationToken ct = default) + { + var exists = await DbSetRole + .AnyAsync(x => + x.Tenant == _tenant && + x.NormalizedName == role.NormalizedName && + x.DeletedAt == null, + ct); + + if (exists) + throw new UAuthConflictException("role_already_exists"); + + var entity = RoleMapper.ToProjection(role); + + DbSetRole.Add(entity); + + var permissionEntities = role.Permissions + .Select(p => RolePermissionMapper.ToProjection(_tenant, role.Id, p)); + + DbSetPermission.AddRange(permissionEntities); + + await _db.SaveChangesAsync(ct); + } + + public async Task GetAsync(RoleKey key, CancellationToken ct = default) + { + var entity = await DbSetRole + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.Id == key.RoleId, + ct); + + if (entity is null) + return null; + + var permissions = await DbSetPermission + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.RoleId == key.RoleId) + .ToListAsync(ct); + + return RoleMapper.ToDomain(entity, permissions); + } + + public async Task SaveAsync(Role role, long expectedVersion, CancellationToken ct = default) + { + var entity = await DbSetRole + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.Id == role.Id, + ct); + + if (entity is null) + throw new UAuthNotFoundException("role_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("role_version_conflict"); + + if (entity.NormalizedName != role.NormalizedName) + { + var exists = await DbSetRole + .AnyAsync(x => + x.Tenant == _tenant && + x.NormalizedName == role.NormalizedName && + x.Id != role.Id && + x.DeletedAt == null, + ct); + + if (exists) + throw new UAuthConflictException("role_name_already_exists"); + } + + RoleMapper.UpdateProjection(role, entity); + entity.Version++; + + var existingPermissions = await DbSetPermission + .Where(x => + x.Tenant == _tenant && + x.RoleId == role.Id) + .ToListAsync(ct); + + DbSetPermission.RemoveRange(existingPermissions); + + var newPermissions = role.Permissions + .Select(p => RolePermissionMapper.ToProjection(_tenant, role.Id, p)); + + DbSetPermission.AddRange(newPermissions); + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(RoleKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entity = await DbSetRole + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.Id == key.RoleId, + ct); + + if (entity is null) + throw new UAuthNotFoundException("role_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("role_version_conflict"); + + if (mode == DeleteMode.Hard) + { + await DbSetPermission + .Where(x => + x.Tenant == _tenant && + x.RoleId == key.RoleId) + .ExecuteDeleteAsync(ct); + + DbSetRole.Remove(entity); + } + else + { + entity.DeletedAt = now; + entity.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task GetByNameAsync(string normalizedName, CancellationToken ct = default) + { + var entity = await DbSetRole + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.NormalizedName == normalizedName && + x.DeletedAt == null, + ct); + + if (entity is null) + return null; + + var permissions = await DbSetPermission + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.RoleId == entity.Id) + .ToListAsync(ct); + + return RoleMapper.ToDomain(entity, permissions); + } + + public async Task> GetByIdsAsync( + IReadOnlyCollection roleIds, + CancellationToken ct = default) + { + var entities = await DbSetRole + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + roleIds.Contains(x.Id)) + .ToListAsync(ct); + + var roleIdsSet = entities.Select(x => x.Id).ToList(); + + var permissions = await DbSetPermission + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + roleIdsSet.Contains(x.RoleId)) + .ToListAsync(ct); + + var permissionLookup = permissions + .GroupBy(x => x.RoleId) + .ToDictionary(x => x.Key); + + var result = new List(entities.Count); + + foreach (var entity in entities) + { + permissionLookup.TryGetValue(entity.Id, out var perms); + + result.Add(RoleMapper.ToDomain(entity, perms ?? Enumerable.Empty())); + } + + return result.AsReadOnly(); + } + + public async Task> QueryAsync( + RoleQuery query, + CancellationToken ct = default) + { + var normalized = query.Normalize(); + + var baseQuery = DbSetRole + .AsNoTracking() + .Where(x => x.Tenant == _tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim().ToUpperInvariant(); + baseQuery = baseQuery.Where(x => x.NormalizedName.Contains(search)); + } + + baseQuery = query.SortBy switch + { + nameof(Role.CreatedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(Role.UpdatedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.UpdatedAt) : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(Role.Name) => + query.Descending ? baseQuery.OrderByDescending(x => x.Name) : baseQuery.OrderBy(x => x.Name), + + nameof(Role.NormalizedName) => + query.Descending ? baseQuery.OrderByDescending(x => x.NormalizedName) : baseQuery.OrderBy(x => x.NormalizedName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + var result = items + .Select(x => RoleMapper.ToDomain(x, Enumerable.Empty())) + .ToList() + .AsReadOnly(); + + return new PagedResult( + result, + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs new file mode 100644 index 00000000..ed02cd5e --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreRoleStoreFactory.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class EfCoreRoleStoreFactory : IRoleStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreRoleStoreFactory(TDbContext db) + { + _db = db; + } + + public IRoleStore Create(TenantKey tenant) + { + return new EfCoreRoleStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs new file mode 100644 index 00000000..a99234f0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStore.cs @@ -0,0 +1,91 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class EfCoreUserRoleStore : IUserRoleStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreUserRoleStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task> GetAssignmentsAsync(UserKey userKey, CancellationToken ct = default) + { + var entities = await DbSet + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey) + .ToListAsync(ct); + + return entities.Select(UserRoleMapper.ToDomain).ToList().AsReadOnly(); + } + + public async Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + { + var exists = await DbSet + .AnyAsync(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RoleId == roleId, + ct); + + if (exists) + throw new UAuthConflictException("role_already_assigned"); + + var entity = new UserRoleProjection + { + Tenant = _tenant, + UserKey = userKey, + RoleId = roleId, + AssignedAt = assignedAt + }; + + DbSet.Add(entity); + await _db.SaveChangesAsync(ct); + } + + public async Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken ct = default) + { + var entity = await DbSet + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RoleId == roleId, + ct); + + if (entity is null) + return; + + DbSet.Remove(entity); + await _db.SaveChangesAsync(ct); + } + + public async Task RemoveAssignmentsByRoleAsync(RoleId roleId, CancellationToken ct = default) + { + await DbSet + .Where(x => + x.Tenant == _tenant && + x.RoleId == roleId) + .ExecuteDeleteAsync(ct); + } + + public async Task CountAssignmentsAsync(RoleId roleId, CancellationToken ct = default) + { + return await DbSet + .CountAsync(x => + x.Tenant == _tenant && + x.RoleId == roleId, + ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs new file mode 100644 index 00000000..74132289 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/Stores/EfCoreUserRoleStoreFactory.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; + +internal sealed class EfCoreUserRoleStoreFactory : IUserRoleStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreUserRoleStoreFactory(TDbContext db) + { + _db = db; + } + + public IUserRoleStore Create(TenantKey tenant) + { + return new EfCoreUserRoleStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/logo.png b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authorization/CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore/logo.png differ diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj new file mode 100644 index 00000000..90ed11f3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/CodeBeam.UltimateAuth.Authorization.InMemory.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authorization.InMemory + + + In-memory persistence implementation for UltimateAuth Authorization module. + Provides lightweight storage for roles, claims and authorization data. + Suitable for development and testing scenarios only. + + + authentication;authorization;roles;claims;inmemory;auth-framework + logo.png + README.md + + + + + + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..919b6a61 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthAuthorizationInMemory(this IServiceCollection services) + { + services.TryAddSingleton(); + services.TryAddSingleton(); + + return services; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/README.md b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/README.md new file mode 100644 index 00000000..6c89f472 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Authorization InMemory + +In-memory persistence implementation for the UltimateAuth Authorization module. + +## Purpose + +Provides lightweight storage for: + +- Roles +- Claims +- Authorization rules + +## When to use + +- Development +- Testing +- Prototyping + +## โš ๏ธ Not for production + +Authorization data is stored in memory and will be lost when the application restarts. + +## Notes + +- No external dependencies +- Zero configuration required + +## Use instead (production) + +- CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore +- Custom authorization providers \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs new file mode 100644 index 00000000..1c47b32f --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStore.cs @@ -0,0 +1,126 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryRoleStore : InMemoryTenantVersionedStore, IRoleStore +{ + protected override RoleKey GetKey(Role entity) => new(entity.Tenant, entity.Id); + + public InMemoryRoleStore(TenantContext tenant) : base(tenant) + { + } + + protected override void BeforeAdd(Role entity) + { + if (TenantValues().Any(r => + r.NormalizedName == entity.NormalizedName && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_already_exists"); + } + } + + protected override void BeforeSave(Role entity, Role current, long expectedVersion) + { + if (entity.NormalizedName != current.NormalizedName) + { + if (TenantValues().Any(r => + r.NormalizedName == entity.NormalizedName && + r.Id != entity.Id && + !r.IsDeleted)) + { + throw new UAuthConflictException("role_name_already_exists"); + } + } + } + + public Task GetByNameAsync(string normalizedName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var role = TenantValues() + .FirstOrDefault(r => + r.NormalizedName == normalizedName && + !r.IsDeleted); + + return Task.FromResult(role?.Snapshot()); + } + + public Task> GetByIdsAsync(IReadOnlyCollection roleIds, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = roleIds.ToHashSet(); + + var result = TenantValues() + .Where(r => set.Contains(r.Id) && !r.IsDeleted) + .Select(r => r.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } + + public Task> QueryAsync(RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = TenantValues().AsQueryable(); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(r => !r.IsDeleted); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim().ToUpperInvariant(); + + baseQuery = baseQuery.Where(r => + r.NormalizedName.Contains(search)); + } + + baseQuery = query.SortBy switch + { + nameof(Role.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(Role.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(Role.Name) => query.Descending + ? baseQuery.OrderByDescending(x => x.Name) + : baseQuery.OrderBy(x => x.Name), + + nameof(Role.NormalizedName) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedName) + : baseQuery.OrderBy(x => x.NormalizedName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs new file mode 100644 index 00000000..570d507b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryRoleStoreFactory.cs @@ -0,0 +1,14 @@ +๏ปฟusing System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +public sealed class InMemoryRoleStoreFactory : IRoleStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IRoleStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => new InMemoryRoleStore(new TenantContext(t))); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs new file mode 100644 index 00000000..8027360b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStore.cs @@ -0,0 +1,101 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +internal sealed class InMemoryUserRoleStore : IUserRoleStore +{ + private readonly TenantKey _tenant; + private readonly ConcurrentDictionary> _assignments = new(); + + public InMemoryUserRoleStore(TenantContext tenant) + { + _tenant = tenant.Tenant; + } + + public Task> GetAssignmentsAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_assignments.TryGetValue(userKey, out var list)) + { + lock (list) + return Task.FromResult>(list.Select(x => x).ToArray()); + } + + return Task.FromResult>(Array.Empty()); + } + + public Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var list = _assignments.GetOrAdd(userKey, _ => new List()); + + lock (list) + { + if (list.Any(x => x.RoleId == roleId)) + throw new UAuthConflictException("role_already_assigned"); + + list.Add(new UserRole + { + Tenant = _tenant, + UserKey = userKey, + RoleId = roleId, + AssignedAt = assignedAt + }); + } + + return Task.CompletedTask; + } + + public Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_assignments.TryGetValue(userKey, out var list)) + { + lock (list) + { + list.RemoveAll(x => x.RoleId == roleId); + } + } + + return Task.CompletedTask; + } + + public Task RemoveAssignmentsByRoleAsync(RoleId roleId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var list in _assignments.Values) + { + lock (list) + { + list.RemoveAll(x => x.RoleId == roleId); + } + } + + return Task.CompletedTask; + } + + public Task CountAssignmentsAsync(RoleId roleId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var count = 0; + + foreach (var list in _assignments.Values) + { + lock (list) + { + count += list.Count(x => x.RoleId == roleId); + } + } + + return Task.FromResult(count); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs new file mode 100644 index 00000000..9e8c8723 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/Stores/InMemoryUserRoleStoreFactory.cs @@ -0,0 +1,14 @@ +๏ปฟusing System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.InMemory; + +public sealed class InMemoryUserRoleStoreFactory : IUserRoleStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IUserRoleStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => new InMemoryUserRoleStore(new TenantContext(t))); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/logo.png b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authorization/CodeBeam.UltimateAuth.Authorization.InMemory/logo.png differ diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj new file mode 100644 index 00000000..1eff5491 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/CodeBeam.UltimateAuth.Authorization.Reference.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authorization.Reference + + + Default reference implementation for the UltimateAuth Authorization module. + Provides role-based and policy-based authorization including claims resolution and access evaluation. + + + authorization;roles;claims;rbac;policy;security;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs new file mode 100644 index 00000000..ae13fda7 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Endpoints/AuthorizationEndpointHandler.cs @@ -0,0 +1,253 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +public sealed class AuthorizationEndpointHandler : IAuthorizationEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAuthorizationService _authorization; + private readonly IUserRoleService _userRoles; + private readonly IRoleService _roles; + private readonly IAccessContextFactory _accessContextFactory; + + public AuthorizationEndpointHandler( + IAuthFlowContextAccessor authFlow, + IAuthorizationService authorization, + IUserRoleService userRoles, + IRoleService roles, + IAccessContextFactory accessContextFactory) + { + _authFlow = authFlow; + _authorization = authorization; + _userRoles = userRoles; + _roles = roles; + _accessContextFactory = accessContextFactory; + } + + + public async Task CheckAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + 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."); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: req.Action, + resource: req.Resource, + resourceId: req.ResourceId + ); + + var result = await _authorization.AuthorizeAsync(accessContext, ctx.RequestAborted); + + if (result.RequiresReauthentication) + return Results.StatusCode(StatusCodes.Status428PreconditionRequired); + + return result.IsAllowed + ? Results.Ok(result) + : Results.Forbid(); + } + + public async Task GetMyRolesAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.GetSelf, + resource: "authorization.roles", + resourceId: flow.UserKey!.Value + ); + + var roles = await _userRoles.GetRolesAsync(accessContext, flow.UserKey!.Value, req, ctx.RequestAborted); + return Results.Ok(new UserRolesResponse + { + UserKey = flow.UserKey!.Value, + Roles = roles + }); + + } + + public async Task GetUserRolesAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.GetAdmin, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + var roles = await _userRoles.GetRolesAsync(accessContext, userKey, req, ctx.RequestAborted); + + return Results.Ok(new UserRolesResponse + { + UserKey = userKey, + Roles = roles + }); + } + + public async Task AssignRoleAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.AssignAdmin, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + await _userRoles.AssignAsync(accessContext, userKey, req.RoleName, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task RemoveRoleAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.RemoveAdmin, + resource: "authorization.roles", + resourceId: userKey.Value + ); + + await _userRoles.RemoveAsync(accessContext, userKey, req.RoleName, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task CreateRoleAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.CreateAdmin, + resource: "authorization.roles" + ); + + var role = await _roles.CreateAsync(accessContext, req.Name, req.Permissions, ctx.RequestAborted); + + return Results.Ok(role); + } + + public async Task RenameRoleAsync(RoleId roleId, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.RenameAdmin, + resource: "authorization.roles", + resourceId: roleId.ToString() + ); + + await _roles.RenameAsync(accessContext, roleId, req.Name, ctx.RequestAborted); + + return Results.Ok(); + } + + public async Task DeleteRoleAsync(RoleId roleId, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.DeleteAdmin, + resource: "authorization.roles", + resourceId: roleId.ToString() + ); + + var result = await _roles.DeleteAsync(accessContext, roleId, req.Mode, ctx.RequestAborted); + + return Results.Ok(result); + } + public async Task SetRolePermissionsAsync(RoleId roleId, HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.SetPermissionsAdmin, + resource: "authorization.roles", + resourceId: roleId.ToString() + ); + + await _roles.SetPermissionsAsync(accessContext, roleId, req.Permissions, ctx.RequestAborted); + + return Results.Ok(); + } + + public async Task QueryRolesAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var req = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Authorization.Roles.QueryAdmin, + resource: "authorization.roles" + ); + + var result = await _roles.QueryAsync(accessContext, req, ctx.RequestAborted); + + return Results.Ok(result); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..49c5f58a --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Endpoints; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Authorization.Reference.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthAuthorizationReference(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs new file mode 100644 index 00000000..9073946d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/RolePermissionResolver.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class RolePermissionResolver : IRolePermissionResolver +{ + private readonly IRoleStoreFactory _roleFactory; + + public RolePermissionResolver(IRoleStoreFactory roleFactory) + { + _roleFactory = roleFactory; + } + + public async Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roleIds, CancellationToken ct = default) + { + if (roleIds.Count == 0) + return Array.Empty(); + + var roleStore = _roleFactory.Create(tenant); + var roles = await roleStore.GetByIdsAsync(roleIds, ct); + + var permissions = new HashSet(); + + foreach (var role in roles) + { + foreach (var perm in role.Permissions) + permissions.Add(perm); + } + + return permissions.ToArray(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs new file mode 100644 index 00000000..0b804c03 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Infrastructure/UserPermissionStore.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class UserPermissionStore : IUserPermissionStore +{ + private readonly IUserRoleStoreFactory _userRolesFactory; + private readonly IRolePermissionResolver _resolver; + + public UserPermissionStore(IUserRoleStoreFactory userRolesFactory, IRolePermissionResolver resolver) + { + _userRolesFactory = userRolesFactory; + _resolver = resolver; + } + + public async Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var userRoleStore = _userRolesFactory.Create(tenant); + var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + return await _resolver.ResolveAsync(tenant, roleIds, ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/README.md b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/README.md new file mode 100644 index 00000000..d4b8bd9b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/README.md @@ -0,0 +1,44 @@ +๏ปฟ# UltimateAuth Authorization Reference + +Default reference implementation for the UltimateAuth Authorization module. + +## Purpose + +This package provides a ready-to-use authorization system including: + +- Role-based access control (RBAC) +- Claims-based authorization +- Policy evaluation + +## Usage + +This package is automatically wired when used with: + +- CodeBeam.UltimateAuth.Server + +## Features + +- Claims resolution +- Role mapping +- Access policy evaluation + +## โš ๏ธ Important + +This is a reference implementation. + +You can: + +- Replace it partially or completely +- Integrate external authorization systems +- Define custom policies + +## Architecture Notes + +This package currently depends on the server runtime. + +Future versions will move towards a fully decoupled plugin model. + +## When to NOT use this package + +- When using external identity providers with built-in authorization +- When implementing a fully custom authorization system \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs new file mode 100644 index 00000000..720bd196 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/AuthorizationService.cs @@ -0,0 +1,36 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class AuthorizationService : IAuthorizationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + + public AuthorizationService(IAccessOrchestrator accessOrchestrator) + { + _accessOrchestrator = accessOrchestrator; + } + + public async Task AuthorizeAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(innerCt => + { + return Task.FromResult(AuthorizationResult.Allow()); + }); + + try + { + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + return AuthorizationResult.Allow(); + } + catch (UAuthAuthorizationException ex) + { + return AuthorizationResult.Deny(ex.Message); + } + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs new file mode 100644 index 00000000..35d09545 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/IAuthorizationService.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +public interface IAuthorizationService +{ + Task AuthorizeAsync(AccessContext context, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs new file mode 100644 index 00000000..6ffed5e2 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/RoleService.cs @@ -0,0 +1,130 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class RoleService : IRoleService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IRoleStoreFactory _roleFactory; + private readonly IUserRoleStoreFactory _userRoleFactory; + private readonly IClock _clock; + + public RoleService( + IAccessOrchestrator accessOrchestrator, + IRoleStoreFactory roleFactory, + IUserRoleStoreFactory userRoleFactory, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _roleFactory = roleFactory; + _userRoleFactory = userRoleFactory; + _clock = clock; + } + + public async Task CreateAsync(AccessContext context, string name, IEnumerable? permissions, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var role = Role.Create(RoleId.New(), context.ResourceTenant, name, permissions, _clock.UtcNow); + var roleStore = _roleFactory.Create(context.ResourceTenant); + await roleStore.AddAsync(role, innerCt); + + return role; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var roleStore = _roleFactory.Create(context.ResourceTenant); + var role = await roleStore.GetAsync(key, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + var expected = role.Version; + role.Rename(newName, _clock.UtcNow); + + await roleStore.SaveAsync(role, expected, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var key = new RoleKey(context.ResourceTenant, roleId); + var roleStore = _roleFactory.Create(context.ResourceTenant); + var role = await roleStore.GetAsync(key, innerCt); + + if (role is null) + throw new UAuthNotFoundException("role_not_found"); + + var userRoleStore = _userRoleFactory.Create(context.ResourceTenant); + var removed = await userRoleStore.CountAssignmentsAsync(roleId, innerCt); + await userRoleStore.RemoveAssignmentsByRoleAsync(roleId, innerCt); + await roleStore.DeleteAsync(key, role.Version, mode, _clock.UtcNow, innerCt); + + return new DeleteRoleResult + { + RoleId = roleId, + RemovedAssignments = removed, + Mode = mode, + DeletedAt = _clock.UtcNow + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var roleStore = _roleFactory.Create(context.ResourceTenant); + var key = new RoleKey(context.ResourceTenant, roleId); + var role = await roleStore.GetAsync(key, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + var expected = role.Version; + role.SetPermissions(permissions, _clock.UtcNow); + + await roleStore.SaveAsync(role, expected, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task> QueryAsync(AccessContext context, RoleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand>(async innerCt => + { + var roleStore = _roleFactory.Create(context.ResourceTenant); + return await roleStore.QueryAsync(query, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs new file mode 100644 index 00000000..11b3d28b --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/Services/UserRoleService.cs @@ -0,0 +1,110 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Authorization.Reference; + +internal sealed class UserRoleService : IUserRoleService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserRoleStoreFactory _userRoleFactory; + private readonly IRoleStoreFactory _roleFactory; + private readonly IClock _clock; + + public UserRoleService(IAccessOrchestrator accessOrchestrator, IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _userRoleFactory = userRoleFactory; + _roleFactory = roleFactory; + _clock = clock; + } + + public async Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var now = _clock.UtcNow; + + var cmd = new AccessCommand(async innerCt => + { + var roleStore = _roleFactory.Create(context.ResourceTenant); + var userRoleStore = _userRoleFactory.Create(context.ResourceTenant); + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await roleStore.GetByNameAsync(normalized, innerCt); + + if (role is null || role.IsDeleted) + throw new UAuthNotFoundException("role_not_found"); + + await userRoleStore.AssignAsync(targetUserKey, role.Id, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var roleStore = _roleFactory.Create(context.ResourceTenant); + var userRoleStore = _userRoleFactory.Create(context.ResourceTenant); + var normalized = roleName.Trim().ToUpperInvariant(); + var role = await roleStore.GetByNameAsync(normalized, innerCt); + + if (role is null) + return; + + await userRoleStore.RemoveAsync(targetUserKey, role.Id, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, PageRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand>(async innerCt => + { + request = request.Normalize(); + + var roleStore = _roleFactory.Create(context.ResourceTenant); + var userRoleStore = _userRoleFactory.Create(context.ResourceTenant); + var assignments = await userRoleStore.GetAssignmentsAsync(targetUserKey, innerCt); + var roleIds = assignments.Select(x => x.RoleId).ToArray(); + var roles = await roleStore.GetByIdsAsync(roleIds, innerCt); + + var roleMap = roles.ToDictionary(x => x.Id); + + var joined = assignments + .Where(a => roleMap.ContainsKey(a.RoleId)) + .Select(a => new UserRoleInfo + { + Tenant = a.Tenant, + UserKey = a.UserKey, + RoleId = a.RoleId, + AssignedAt = a.AssignedAt, + Name = roleMap[a.RoleId].Name + }) + .ToList(); + + var total = joined.Count; + + var pageItems = joined.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList(); + + return new PagedResult( + pageItems, + total, + request.PageNumber, + request.PageSize, + request.SortBy, + request.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/logo.png b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authorization/CodeBeam.UltimateAuth.Authorization.Reference/logo.png differ diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs new file mode 100644 index 00000000..18c59bf3 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRolePermissionResolver.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRolePermissionResolver +{ + Task> ResolveAsync(TenantKey tenant, IReadOnlyCollection roles, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs new file mode 100644 index 00000000..948959f6 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleService.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleService +{ + Task CreateAsync(AccessContext context, string name, IEnumerable? permissions, CancellationToken ct = default); + + Task RenameAsync(AccessContext context, RoleId roleId, string newName, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, RoleId roleId, DeleteMode mode, CancellationToken ct = default); + + Task SetPermissionsAsync(AccessContext context, RoleId roleId, IEnumerable permissions, CancellationToken ct = default); + + Task> QueryAsync(AccessContext context, RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs new file mode 100644 index 00000000..c771c3bd --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStore.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleStore : IVersionedStore +{ + Task GetByNameAsync(string normalizedName, CancellationToken ct = default); + Task> GetByIdsAsync(IReadOnlyCollection roleIds, CancellationToken ct = default); + Task> QueryAsync(RoleQuery query, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStoreFactory.cs new file mode 100644 index 00000000..73386874 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IRoleStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IRoleStoreFactory +{ + IRoleStore Create(TenantKey tenant); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs new file mode 100644 index 00000000..bcf221a8 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserPermissionStore.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserPermissionStore +{ + Task> GetPermissionsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs new file mode 100644 index 00000000..ba96b03d --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleService.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserRoleService +{ + Task AssignAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default); + Task RemoveAsync(AccessContext context, UserKey targetUserKey, string roleName, CancellationToken ct = default); + Task> GetRolesAsync(AccessContext context, UserKey targetUserKey, PageRequest request, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs new file mode 100644 index 00000000..d8918a24 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStore.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserRoleStore +{ + Task> GetAssignmentsAsync(UserKey userKey, CancellationToken ct = default); + Task AssignAsync(UserKey userKey, RoleId roleId, DateTimeOffset assignedAt, CancellationToken ct = default); + Task RemoveAsync(UserKey userKey, RoleId roleId, CancellationToken ct = default); + Task RemoveAssignmentsByRoleAsync(RoleId roleId, CancellationToken ct = default); + Task CountAssignmentsAsync(RoleId roleId, CancellationToken ct = default); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStoreFactory.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStoreFactory.cs new file mode 100644 index 00000000..3dfe72a6 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Abstractions/IUserRoleStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public interface IUserRoleStoreFactory +{ + IUserRoleStore Create(TenantKey tenant); +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj new file mode 100644 index 00000000..50c5dc51 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/CodeBeam.UltimateAuth.Authorization.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Authorization + + + Authorization module for UltimateAuth. + Provides orchestration, abstractions and dependency injection wiring for role and permission based authorization. + Use with a persistence provider such as EntityFrameworkCore or InMemory. + This package is included transitively by CodeBeam.UltimateAuth.Server and usually does not need to be installed directly. + + + authentication;authorization;roles;permissions;security;module;auth-framework + logo.png + README.md + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs new file mode 100644 index 00000000..74995da2 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/Role.cs @@ -0,0 +1,163 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class Role : ITenantEntity, IVersionedEntity, IEntitySnapshot, ISoftDeletable +{ + private readonly HashSet _permissions = new(); + + public RoleId Id { get; private set; } + public TenantKey Tenant { get; private set; } + + public string Name { get; private set; } = default!; + public string NormalizedName { get; private set; } = default!; + + public IReadOnlyCollection Permissions => _permissions; + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + + private Role() { } + + public static Role Create( + RoleId? id, + TenantKey tenant, + string name, + IEnumerable? permissions, + DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(name)) + throw new UAuthValidationException("role_name_required"); + + var normalized = Normalize(name); + + var role = new Role + { + Id = id ?? RoleId.New(), + Tenant = tenant, + Name = name.Trim(), + NormalizedName = normalized, + CreatedAt = now, + Version = 0 + }; + + if (permissions is not null) + { + foreach (var p in permissions) + role._permissions.Add(p); + } + + return role; + } + + public Role Rename(string newName, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(newName)) + throw new UAuthValidationException("role_name_required"); + + if (NormalizedName == Normalize(newName)) + return this; + + Name = newName.Trim(); + NormalizedName = Normalize(newName); + UpdatedAt = now; + + return this; + } + + public Role AddPermission(Permission permission, DateTimeOffset now) + { + _permissions.Add(permission); + UpdatedAt = now; + return this; + } + + public Role RemovePermission(Permission permission, DateTimeOffset now) + { + _permissions.Remove(permission); + UpdatedAt = now; + return this; + } + + public Role SetPermissions(IEnumerable permissions, DateTimeOffset now) + { + _permissions.Clear(); + var normalized = PermissionNormalizer.Normalize(permissions, UAuthPermissionCatalog.GetAdminPermissions()); + + foreach (var p in normalized) + _permissions.Add(p); + + UpdatedAt = now; + return this; + } + + public Role MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } + + public Role Snapshot() + { + var copy = new Role + { + Id = Id, + Tenant = Tenant, + Name = Name, + NormalizedName = NormalizedName, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + + foreach (var p in _permissions) + copy._permissions.Add(p); + + return copy; + } + + public static Role FromProjection( + RoleId id, + TenantKey tenant, + string name, + IEnumerable permissions, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + var role = new Role + { + Id = id, + Tenant = tenant, + Name = name, + NormalizedName = Normalize(name), + CreatedAt = createdAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + + foreach (var p in permissions) + role._permissions.Add(p); + + return role; + } + + private static string Normalize(string name) + => name.Trim().ToUpperInvariant(); +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs new file mode 100644 index 00000000..004ee340 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/RoleKey.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Authorization; + +public readonly record struct RoleKey( + TenantKey Tenant, + RoleId RoleId); \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs new file mode 100644 index 00000000..c0538ff0 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Domain/UserRole.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Authorization.Contracts; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class UserRole +{ + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } + public RoleId RoleId { get; init; } + public DateTimeOffset AssignedAt { get; init; } +} \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs new file mode 100644 index 00000000..7c50a600 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/Infrastructure/AuthorizationClaimsProvider.cs @@ -0,0 +1,43 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Authorization; + +public sealed class AuthorizationClaimsProvider : IUserClaimsProvider +{ + private readonly IUserRoleStoreFactory _userRoleFactory; + private readonly IRoleStoreFactory _roleFactory; + private readonly IUserPermissionStore _permissions; + + public AuthorizationClaimsProvider(IUserRoleStoreFactory userRoleFactory, IRoleStoreFactory roleFactory, IUserPermissionStore permissions) + { + _userRoleFactory = userRoleFactory; + _roleFactory = roleFactory; + _permissions = permissions; + } + + public async Task GetClaimsAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var roleStore = _roleFactory.Create(tenant); + var userRoleStore = _userRoleFactory.Create(tenant); + var assignments = await userRoleStore.GetAssignmentsAsync(userKey, ct); + var roleIds = assignments.Select(x => x.RoleId).Distinct().ToArray(); + var roles = await roleStore.GetByIdsAsync(roleIds, ct); + var perms = await _permissions.GetPermissionsAsync(tenant, userKey, ct); + + var builder = ClaimsSnapshot.Create(); + + builder.Add(UAuthConstants.Claims.Tenant, tenant.Value); + + foreach (var role in roles) + builder.Add(ClaimTypes.Role, role.Name); + + foreach (var perm in perms) + builder.Add(UAuthConstants.Claims.Permission, perm.Value); + + return builder.Build(); + } +} diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/README.md b/src/authorization/CodeBeam.UltimateAuth.Authorization/README.md new file mode 100644 index 00000000..0d78b0b6 --- /dev/null +++ b/src/authorization/CodeBeam.UltimateAuth.Authorization/README.md @@ -0,0 +1,23 @@ +๏ปฟ# UltimateAuth Authorization + +Authorization module for UltimateAuth. + +## Purpose + +This package provides: + +- Dependency injection setup +- Role and permission orchestration +- Integration points for authorization providers + +## Does NOT include + +- Persistence (use EntityFrameworkCore or InMemory packages) +- Domain implementation (use Reference package if needed) +- Policy enforcement integrations + +โš ๏ธ This package is typically installed transitively via: + +- CodeBeam.UltimateAuth.Server + +In most cases, you do not need to install it directly unless you are building custom integrations. \ No newline at end of file diff --git a/src/authorization/CodeBeam.UltimateAuth.Authorization/logo.png b/src/authorization/CodeBeam.UltimateAuth.Authorization/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/authorization/CodeBeam.UltimateAuth.Authorization/logo.png differ diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/AssemblyVisibility.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthenticationStateProvider.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthenticationStateProvider.cs new file mode 100644 index 00000000..2f47278b --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/AuthState/UAuthAuthenticationStateProvider.cs @@ -0,0 +1,20 @@ +๏ปฟusing Microsoft.AspNetCore.Components.Authorization; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +internal sealed class UAuthAuthenticationStateProvider : AuthenticationStateProvider +{ + private readonly IUAuthStateManager _stateManager; + + public UAuthAuthenticationStateProvider(IUAuthStateManager stateManager) + { + _stateManager = stateManager; + _stateManager.State.Changed += _ => NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public override Task GetAuthenticationStateAsync() + { + var principal = _stateManager.State.ToClaimsPrincipal(); + return Task.FromResult(new AuthenticationState(principal)); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj new file mode 100644 index 00000000..5a9a7ac4 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/CodeBeam.UltimateAuth.Client.Blazor.csproj @@ -0,0 +1,64 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Client.Blazor + + + Blazor client integration for UltimateAuth. + Provides authentication state management, token handling and UI integration. + + + authentication;authorization;identity;blazor;wasm;aspnetcore;client;auth;oauth;pkce;jwt;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + PreserveNewest + + + + + + + + + + + + + + + + + + diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs new file mode 100644 index 00000000..8f805211 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthFlowPageBase.cs @@ -0,0 +1,106 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public abstract class UAuthFlowPageBase : UAuthReactiveComponentBase +{ + [Inject] protected NavigationManager Nav { get; set; } = default!; + + protected AuthFlowPayload? UAuthPayload { get; private set; } + protected string? ReturnUrl { get; private set; } + protected bool ShouldFocus { get; private set; } + protected string? Identifier { get; private set; } + + + protected virtual bool ClearQueryAfterParse => true; + + private bool _needsClear; + private string? _lastParsedUri; + private bool _payloadConsumed; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + + var currentUri = Nav.Uri; + + if (string.Equals(_lastParsedUri, currentUri, StringComparison.Ordinal)) + return; + + _lastParsedUri = currentUri; + + _payloadConsumed = false; + + var uri = Nav.ToAbsoluteUri(currentUri); + var query = QueryHelpers.ParseQuery(uri.Query); + + ShouldFocus = query.TryGetValue("focus", out var focus) && focus == "1"; + ReturnUrl = query.TryGetValue("returnUrl", out var ru) ? ru.ToString() : null; + Identifier = query.TryGetValue("identifier", out var id) ? id.ToString() : null; + + UAuthPayload = null; + + if (query.TryGetValue("uauth", out var raw) && !string.IsNullOrWhiteSpace(raw)) + { + try + { + var bytes = WebEncoders.Base64UrlDecode(raw!); + var json = Encoding.UTF8.GetString(bytes); + UAuthPayload = JsonSerializer.Deserialize(json); + } + catch + { + UAuthPayload = null; + } + } + + _needsClear = ClearQueryAfterParse && uri.Query.Length > 1; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await base.OnAfterRenderAsync(firstRender); + + if (TryConsumePayload(out var payload)) + await OnUAuthPayloadAsync(payload!); + + if (ConsumeFocus()) + await OnFocusRequestedAsync(); + + if (_needsClear) + { + _needsClear = false; + var clean = new Uri(Nav.Uri).GetLeftPart(UriPartial.Path); + Nav.NavigateTo(clean, replace: true); + } + } + + protected bool ConsumeFocus() + { + if (!ShouldFocus) + return false; + + ShouldFocus = false; + return true; + } + + protected bool TryConsumePayload(out AuthFlowPayload? payload) + { + if (_payloadConsumed || UAuthPayload is null) + { + payload = null; + return false; + } + + _payloadConsumed = true; + payload = UAuthPayload; + return true; + } + + protected virtual Task OnUAuthPayloadAsync(AuthFlowPayload payload) => Task.CompletedTask; + protected virtual Task OnFocusRequestedAsync() => Task.CompletedTask; +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs new file mode 100644 index 00000000..48b248c3 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubLayoutComponentBase.cs @@ -0,0 +1,59 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public abstract class UAuthHubLayoutComponentBase : LayoutComponentBase +{ + [Inject] protected NavigationManager Navigation { get; set; } = default!; + [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!; + + protected HubFlowState? HubState { get; private set; } + + protected bool HasHub => HubState?.Exists == true; + protected bool IsHubAuthorized => HasHub && HubState?.IsActive == true; + protected bool IsExpired => HubState?.IsExpired == true; + protected HubErrorCode? Error => HubState?.Error; + + private string? _lastHubKey; + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + var hubKey = ResolveHubKey(); + + if (string.IsNullOrWhiteSpace(hubKey)) + { + HubState = null; + return; + } + + if (_lastHubKey == hubKey && HubState is not null) + return; + + _lastHubKey = hubKey; + + if (HubSessionId.TryParse(hubKey, out var hubId)) + { + HubState = await HubFlowReader.GetStateAsync(hubId); + } + else + { + HubState = null; + } + } + + protected virtual string? ResolveHubKey() + { + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue)) + return hubValue.ToString(); + + return null; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs new file mode 100644 index 00000000..2fc629d1 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthHubPageBase.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public abstract class UAuthHubPageBase : UAuthReactiveComponentBase +{ + [Inject] protected IHubFlowReader HubFlowReader { get; set; } = default!; + [Inject] protected NavigationManager Nav { get; set; } = default!; + + [Parameter] + [SupplyParameterFromQuery(Name = UAuthConstants.Query.Hub)] + public string? HubKey { get; set; } + + protected HubFlowState? HubState { get; private set; } + + protected bool IsHubAuthorized => HubState is { Exists: true, IsActive: true }; + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + await ReloadState(); + } + + public async Task ReloadState() + { + if (string.IsNullOrWhiteSpace(HubKey)) + { + HubState = null; + return; + } + + if (HubSessionId.TryParse(HubKey, out var id)) + { + HubState = await HubFlowReader.GetStateAsync(id); + } + else + { + HubState = null; + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs new file mode 100644 index 00000000..fb67afe8 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/Base/UAuthReactiveComponentBase.cs @@ -0,0 +1,56 @@ +๏ปฟusing Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public abstract class UAuthReactiveComponentBase : ComponentBase, IDisposable +{ + private UAuthState? _previousState; + + [CascadingParameter] + protected UAuthState AuthState { get; set; } = default!; + + /// + /// Automatically re-render when UAuthState changes. + /// Can be overridden to disable. + /// + protected virtual bool AutoRefreshOnAuthStateChanged => true; + + protected override void OnParametersSet() + { + base.OnParametersSet(); + + if (AuthState is null) + throw new InvalidOperationException($"{GetType().Name} requires a cascading parameter of type {nameof(AuthState)}. " + + $"Make sure it is used inside ."); + + if (!ReferenceEquals(_previousState, AuthState)) + { + if (_previousState is not null) + _previousState.Changed -= OnAuthStateChanged; + + AuthState.Changed += OnAuthStateChanged; + _previousState = AuthState; + } + } + + private void OnAuthStateChanged(UAuthStateChangeReason reason) + { + HandleAuthStateChanged(reason); + + if (AutoRefreshOnAuthStateChanged) + _ = InvokeAsync(StateHasChanged); + } + + /// + /// Allows derived components to react to auth state changes. + /// + protected virtual void HandleAuthStateChanged(UAuthStateChangeReason reason) + { + } + + public virtual void Dispose() + { + if (_previousState is not null) + _previousState.Changed -= OnAuthStateChanged; + } +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor new file mode 100644 index 00000000..d0b1a1ac --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor @@ -0,0 +1,51 @@ +๏ปฟ@namespace CodeBeam.UltimateAuth.Client.Blazor + +@using CodeBeam.UltimateAuth.Client.Contracts +@using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.AspNetCore.Components.Routing +@inject IUAuthStateManager StateManager +@inject IUAuthClientBootstrapper Bootstrapper +@inject ISessionCoordinator Coordinator + + + + @if (RenderMode == UAuthRenderMode.Reactive) + { + + @ChildContent + @if (UseBuiltInRouter) + { + @RenderRouter() + } + + } + else + { + @ChildContent + @if (UseBuiltInRouter) + { + @RenderRouter() + } + } + + + +@code { + private RenderFragment RenderRouter() => @ + + + + @if (NotAuthorized is not null) + { + @NotAuthorized + } + else + { +

Not authorized

+ } +
+
+ +
+
; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs new file mode 100644 index 00000000..ef31c4b2 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthApp.razor.cs @@ -0,0 +1,127 @@ +๏ปฟusing Microsoft.AspNetCore.Components; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public partial class UAuthApp +{ + private bool _initialized; + private bool _coordinatorStarted; + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public RenderFragment? NotAuthorized { get; set; } + + [Parameter] + public bool UseBuiltInRouter { get; set; } + + [Parameter] + public bool UseUAuthClientRoutes { get; set; } = true; + + [Parameter] + public Assembly? AppAssembly { get; set; } + + [Parameter] + public IEnumerable? AdditionalAssemblies { get; set; } + + [Parameter] + public Type? DefaultLayout { get; set; } + + [Parameter] + public string? FocusSelector { get; set; } = "h1"; + + [Parameter] + public UAuthRenderMode RenderMode { get; set; } = UAuthRenderMode.Manual; + + [Parameter] + public EventCallback OnReauthRequired { get; set; } + + protected override async Task OnInitializedAsync() + { + Coordinator.ReauthRequired += HandleReauthRequired; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + if (_initialized) + return; + + _initialized = true; + + StateManager.State.RequestRender = () => InvokeAsync(StateHasChanged); + + await Bootstrapper.EnsureStartedAsync(); + await StateManager.EnsureAsync(); + + if (StateManager.State.IsAuthenticated) + { + await Coordinator.StartAsync(); + _coordinatorStarted = true; + } + + StateManager.State.Changed += OnStateChanged; + + StateHasChanged(); + } + + if (StateManager.State.NeedsValidation) + { + await StateManager.EnsureAsync(true); + } + } + + private void OnStateChanged(UAuthStateChangeReason reason) + { + if (reason == UAuthStateChangeReason.Authenticated) + { + _ = InvokeAsync(async () => + { + await Coordinator.StartAsync(); + }); + } + + if (reason == UAuthStateChangeReason.Cleared) + { + _ = InvokeAsync(async () => + { + await Coordinator.StopAsync(); + }); + } + + if (RenderMode == UAuthRenderMode.Reactive) + { + InvokeAsync(StateHasChanged); + } + } + + private async void HandleReauthRequired() + { + StateManager.MarkStale(); + if (OnReauthRequired.HasDelegate) + await OnReauthRequired.InvokeAsync(); + } + + private IEnumerable GetAdditionalAssemblies() + { + if (AdditionalAssemblies is null && UseUAuthClientRoutes) + return UAuthAssemblies.BlazorClient(); + + if (UseUAuthClientRoutes) + return AdditionalAssemblies.WithUltimateAuth(); + + return Enumerable.Empty(); + } + + public async ValueTask DisposeAsync() + { + StateManager.State.Changed -= OnStateChanged; + Coordinator.ReauthRequired -= HandleReauthRequired; + + if (_coordinatorStarted) + await Coordinator.StopAsync(); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor new file mode 100644 index 00000000..af62ef65 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginDispatch.razor @@ -0,0 +1,55 @@ +๏ปฟ@page "/__uauth/login-redirect" + +@namespace CodeBeam.UltimateAuth.Client.Blazor +@using CodeBeam.UltimateAuth.Core.Defaults +@using Microsoft.AspNetCore.WebUtilities +@inject NavigationManager Nav + +@code { + protected override void OnAfterRender(bool firstRender) + { + if (!firstRender) + return; + + var uri = Nav.ToAbsoluteUri(Nav.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + var freshLogin = query.ContainsKey("fresh"); + + var returnUrl = query.TryGetValue(UAuthConstants.Query.ReturnUrl, out var value) + ? value.ToString() + : null; + + var loginRoute = UAuthLoginPageDiscovery.Resolve(); + + string target; + string? safeReturnUrl = null; + + if (!string.IsNullOrWhiteSpace(returnUrl)) + { + returnUrl = returnUrl.Trim(); + + if (returnUrl.StartsWith("/") || + returnUrl.StartsWith("./") || + returnUrl.StartsWith("../")) + { + safeReturnUrl = returnUrl; + } + else if (Uri.TryCreate(returnUrl, UriKind.Absolute, out var abs) && (abs.Scheme == Uri.UriSchemeHttp || abs.Scheme == Uri.UriSchemeHttps)) + { + safeReturnUrl = returnUrl; + } + } + + if (freshLogin || string.IsNullOrWhiteSpace(safeReturnUrl)) + { + target = loginRoute; + } + else + { + target = $"{loginRoute}?{UAuthConstants.Query.ReturnUrl}={Uri.EscapeDataString(safeReturnUrl)}"; + } + + Nav.NavigateTo(target, true); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor new file mode 100644 index 00000000..93eb2736 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor @@ -0,0 +1,39 @@ +๏ปฟ@* TODO: Optional double-submit prevention for native form submit *@ +@namespace CodeBeam.UltimateAuth.Client.Blazor + +@using CodeBeam.UltimateAuth.Client.Device +@using CodeBeam.UltimateAuth.Client.Options +@using CodeBeam.UltimateAuth.Core.Abstractions +@using CodeBeam.UltimateAuth.Core.Defaults +@using CodeBeam.UltimateAuth.Core.Contracts +@using CodeBeam.UltimateAuth.Core.Options +@using Microsoft.Extensions.Options +@inject IJSRuntime JS +@inject IOptions Options +@inject NavigationManager Navigation + +
+ + + + + + + @if (LoginType == UAuthLoginType.Pkce) + { + + + } + + @if (SubmitMode == UAuthSubmitMode.DirectCommit) + { + + } + + @ChildContent + + @if (AllowEnterKeyToSubmit) + { + + } +
diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs new file mode 100644 index 00000000..8696afd6 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthLoginForm.razor.cs @@ -0,0 +1,325 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public partial class UAuthLoginForm +{ + [Inject] + IDeviceIdProvider DeviceIdProvider { get; set; } = null!; + + [Inject] + IUAuthClient UAuthClient { get; set; } = null!; + + [Inject] + IHubCredentialResolver HubCredentialResolver { get; set; } = null!; + + [Inject] + IHubFlowReader HubFlowReader { get; set; } = null!; + + [Inject] + IHubCapabilities HubCapabilities { get; set; } = null!; + + /// + /// Gets or sets the unique login identifier associated with this component instance. + /// + [Parameter] + public string? Identifier { get; set; } + + /// + /// Gets or sets the secret value used for authentication or configuration. + /// + [Parameter] + public string? Secret { get; set; } + + /// + /// Gets or sets the endpoint URL (login path) used to connect to the target service. If not set, endpoint is set by options. + /// + [Parameter] + public string? Endpoint { get; set; } + + /// + /// Gets or sets the URL to which the user is redirected after a successful operation. If not set, return url is set by hub flow state's return url. + /// + [Parameter] + public string? ReturnUrl { get; set; } + + /// + /// Gets or sets the associated UAuthHub flow id. If not set, value is set from query. + /// + [Parameter] + public HubSessionId? HubSessionId { get; set; } + + /// + /// Gets or sets the type of login method to use for authentication. + /// + [Parameter] + public UAuthLoginType LoginType { get; set; } = UAuthLoginType.Password; + + /// + /// Gets or sets the mode used to submit authentication requests. Default is TryAndCommit. + /// + [Parameter] + public UAuthSubmitMode SubmitMode { get; set; } = UAuthSubmitMode.TryAndCommit; + + /// + /// Gets or sets the content to be rendered inside the component. + /// + /// Use this property to specify child markup or components that will be rendered within the body + /// of this component. Typically set automatically when the component is used with child content in Razor + /// syntax. + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets a value indicating whether pressing the Enter key submits the form. + /// + [Parameter] + public bool AllowEnterKeyToSubmit { get; set; } = true; + + /// + /// Gets or sets the callback that is invoked when a try result event occurs. Does not fire on DirectCommit submit mode. + /// + /// Use this property to handle the result of an authentication attempt. The callback receives an + /// object that provides details about the outcome of the operation. + [Parameter] + public EventCallback OnTryResult { get; set; } + + + private ElementReference _form; + private HubCredentials? _credentials; + private HubFlowState? _flow; + private DeviceId? _deviceId; + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + await ReloadCredentialsAsync(); + await ReloadStateAsync(); + + if (LoginType == UAuthLoginType.Pkce && !HubCapabilities.SupportsPkce) + { + throw new InvalidOperationException("PKCE login requires UAuthHub (Blazor Server). " + + "PKCE is not supported in this client profile." + + "Change LoginType to password or place this component to a server-side project."); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + _deviceId = await DeviceIdProvider.GetOrCreateAsync(); + StateHasChanged(); + } + + protected async Task ReloadCredentialsAsync() + { + if (LoginType != UAuthLoginType.Pkce) + return; + + if (HubCredentialResolver is null || EffectiveHubSessionId is null) + return; + + _credentials = await HubCredentialResolver.ResolveAsync(EffectiveHubSessionId.Value); + } + + protected async Task ReloadStateAsync() + { + if (LoginType != UAuthLoginType.Pkce || EffectiveHubSessionId is null || HubFlowReader is null) + return; + + _flow = await HubFlowReader.GetStateAsync(EffectiveHubSessionId.Value); + } + + /// + /// Asynchronously reloads credentials and state, and updates the component state. + /// + /// Call this method to refresh the component's credentials and state. This method is typically + /// used when external changes require the component to update its internal data and UI. + /// A task that represents the asynchronous reload operation. + public async Task ReloadAsync() + { + await ReloadCredentialsAsync(); + await ReloadStateAsync(); + await InvokeAsync(StateHasChanged); + } + + /// + /// Submits the login form asynchronously using the configured authentication method. + /// + /// A task that represents the asynchronous submit operation. + /// Thrown if the form has not been rendered. Call this method only after the OnAfterRender lifecycle event. + public async Task SubmitAsync() + { + if (_form.Context is null) + throw new InvalidOperationException("Form is not yet rendered. Call SubmitAsync after OnAfterRender."); + + if (LoginType == UAuthLoginType.Pkce) + { + await SubmitPkceAsync(); + } + else + { + await SubmitPasswordAsync(); + } + } + + private async Task SubmitPasswordAsync() + { + if (string.IsNullOrWhiteSpace(Identifier) || string.IsNullOrWhiteSpace(Secret)) + { + throw new UAuthValidationException("Identifier and Secret are required."); + } + + var request = new LoginRequest + { + Identifier = Identifier, + Secret = Secret, + }; + + switch (SubmitMode) + { + case UAuthSubmitMode.DirectCommit: + await JS.InvokeVoidAsync("uauth.submitForm", _form); + break; + + case UAuthSubmitMode.TryOnly: + { + var result = await UAuthClient.Flows.TryLoginAsync(request, UAuthSubmitMode.TryOnly); + await EmitResultAsync(result); + break; + } + + case UAuthSubmitMode.TryAndCommit: + default: + { + var result = await UAuthClient.Flows.TryLoginAsync(request, UAuthSubmitMode.TryAndCommit, EffectiveReturnUrl); + await EmitResultAsync(result); + break; + } + } + } + + private async Task SubmitPkceAsync() + { + if (_credentials is null) + throw new InvalidOperationException("Missing PKCE credentials."); + + if (string.IsNullOrWhiteSpace(Identifier) || string.IsNullOrWhiteSpace(Secret)) + { + throw new UAuthValidationException("Identifier and Secret are required."); + } + + var request = new PkceCompleteRequest + { + Identifier = Identifier, + Secret = Secret, + AuthorizationCode = _credentials.AuthorizationCode, + CodeVerifier = _credentials.CodeVerifier, + ReturnUrl = EffectiveReturnUrl ?? string.Empty, + HubSessionId = EffectiveHubSessionId?.Value ?? string.Empty + }; + + switch (SubmitMode) + { + case UAuthSubmitMode.DirectCommit: + { + await UAuthClient.Flows.CompletePkceLoginAsync(request); + break; + } + + case UAuthSubmitMode.TryOnly: + { + var result = await UAuthClient.Flows.TryCompletePkceLoginAsync(request, UAuthSubmitMode.TryOnly); + await EmitResultAsync(result); + break; + } + + case UAuthSubmitMode.TryAndCommit: + default: + { + var result = await UAuthClient.Flows.TryCompletePkceLoginAsync(request, UAuthSubmitMode.TryAndCommit); + await EmitResultAsync(result); + break; + } + } + } + + private async Task EmitResultAsync(IUAuthTryResult result) + { + if (OnTryResult.HasDelegate) + await OnTryResult.InvokeAsync(result); + } + + private string ClientProfileValue => Options.Value.ClientProfile.ToString(); + + private string EffectiveEndpoint => LoginType == UAuthLoginType.Pkce + ? Options.Value.Endpoints.PkceTryComplete + : Options.Value.Endpoints.Login; + + + private string ResolvedEndpoint + { + get + { + var loginPath = string.IsNullOrWhiteSpace(Endpoint) + ? EffectiveEndpoint + : Endpoint; + + var baseUrl = UAuthUrlBuilder.Build(Options.Value.Endpoints.BasePath, loginPath, Options.Value.MultiTenant); + var returnUrl = EffectiveReturnUrl; + + if (string.IsNullOrWhiteSpace(returnUrl)) + return baseUrl; + + var query = new List(); + + if (_credentials != null && EffectiveHubSessionId is not null) + { + query.Add($"hub={EffectiveHubSessionId}"); + } + + if (!string.IsNullOrWhiteSpace(returnUrl)) + { + query.Add($"{UAuthConstants.Query.ReturnUrl}={Uri.EscapeDataString(returnUrl)}"); + } + + return query.Count == 0 + ? baseUrl + : $"{baseUrl}?{string.Join("&", query)}"; + } + } + + private string? EffectiveReturnUrl => !string.IsNullOrWhiteSpace(ReturnUrl) + ? ReturnUrl + : LoginType == UAuthLoginType.Pkce ? _flow?.ReturnUrl : Navigation.Uri; + + private HubSessionId? EffectiveHubSessionId + { + get + { + if (HubSessionId is not null) + return HubSessionId; + + var uri = Navigation.ToAbsoluteUri(Navigation.Uri); + var query = QueryHelpers.ParseQuery(uri.Query); + + if (query.TryGetValue(UAuthConstants.Query.Hub, out var hubValue) && CodeBeam.UltimateAuth.Core.Domain.HubSessionId.TryParse(hubValue, out var parsed)) + { + return parsed; + } + + return null; + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor new file mode 100644 index 00000000..49e15f69 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor @@ -0,0 +1,4 @@ +๏ปฟ@namespace CodeBeam.UltimateAuth.Client.Blazor +@inherits UAuthReactiveComponentBase + +@ChildContent diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs new file mode 100644 index 00000000..c3c4e2dd --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthScope.razor.cs @@ -0,0 +1,9 @@ +๏ปฟusing Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public partial class UAuthScope : UAuthReactiveComponentBase +{ + [Parameter] + public RenderFragment? ChildContent { get; set; } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor new file mode 100644 index 00000000..fca197bb --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor @@ -0,0 +1,38 @@ +๏ปฟ@namespace CodeBeam.UltimateAuth.Client.Blazor + +@inherits UAuthReactiveComponentBase +@using CodeBeam.UltimateAuth.Core.Domain +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@inject IAuthorizationService AuthorizationService + +@if (_inactive) +{ + if (Inactive is not null) + { + @Inactive(AuthState) + } + else + { + @NotAuthorized + } +} +else if (_authorizing && Authorizing is not null) +{ + @Authorizing +} +else if (!_authorized) +{ + @NotAuthorized +} +else +{ + if (Authorized is not null) + { + @Authorized(AuthState) + } + else if (ChildContent is not null) + { + @ChildContent(AuthState) + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs new file mode 100644 index 00000000..e488b329 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Components/UAuthStateView.razor.cs @@ -0,0 +1,176 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public partial class UAuthStateView : UAuthReactiveComponentBase +{ + private IReadOnlyList _rolesParsed = Array.Empty(); + private IReadOnlyList _permissionsParsed = Array.Empty(); + private bool _authorized; + private bool _inactive; + private bool _authorizing; + private string? _authKey; + private string? _rolesRaw; + private string? _permissionsRaw; + + [Parameter] + public RenderFragment? Authorized { get; set; } + + [Parameter] + public RenderFragment? NotAuthorized { get; set; } + + [Parameter] + public RenderFragment? Inactive { get; set; } + + [Parameter] + public RenderFragment? Authorizing { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? Roles { get; set; } + + [Parameter] + public string? Permissions { get; set; } + + [Parameter] + public string? Policy { get; set; } + + /// + /// Gets or sets a value indicating whether all set conditions must be matched for the operation to succeed. + /// Null parameters don't count as condition. + /// + [Parameter] + public bool MatchAll { get; set; } = true; + + [Parameter] + public bool RequireActive { get; set; } = true; + + protected override async Task OnParametersSetAsync() + { + await base.OnParametersSetAsync(); + + var newKey = BuildAuthKey(); + + if (_authKey == newKey) + return; + + _authKey = newKey; + _authorizing = true; + + if (_rolesRaw != Roles) + { + _rolesRaw = Roles; + _rolesParsed = ParseCsv(Roles); + } + + if (_permissionsRaw != Permissions) + { + _permissionsRaw = Permissions; + _permissionsParsed = ParseCsv(Permissions); + } + + EvaluateSessionState(); + _authorized = await EvaluateAuthorizationAsync(); + _authorizing = false; + } + + protected override async void HandleAuthStateChanged(UAuthStateChangeReason reason) + { + EvaluateSessionState(); + _authorizing = true; + _authorized = await EvaluateAuthorizationAsync(); + _authorizing = false; + await InvokeAsync(StateHasChanged); + } + + private async Task EvaluateAuthorizationAsync() + { + if (!AuthState.IsAuthenticated) + return false; + + var roles = _rolesParsed; + var permissions = _permissionsParsed; + + var results = new List(); + + if (roles.Count > 0) + { + results.Add(MatchAll + ? roles.All(AuthState.IsInRole) + : roles.Any(AuthState.IsInRole)); + } + + if (permissions.Count > 0) + { + results.Add(MatchAll + ? permissions.All(AuthState.HasPermission) + : permissions.Any(AuthState.HasPermission)); + } + + if (!string.IsNullOrWhiteSpace(Policy)) + results.Add(await EvaluatePolicyAsync()); + + if (results.Count == 0) + return true; + + return MatchAll + ? results.All(x => x) + : results.Any(x => x); + } + + private void EvaluateSessionState() + { + if (!RequireActive) + { + _inactive = false; + return; + } + + if (AuthState.IsAuthenticated != true) + { + _inactive = false; + return; + } + + if (AuthState.Identity?.SessionState is null) + { + _inactive = false; + } + else + { + _inactive = AuthState.Identity?.SessionState != SessionState.Active; + } + } + + private static IReadOnlyList ParseCsv(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return Array.Empty(); + + return value + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Trim()) + .Where(x => x.Length > 0) + .ToArray(); + } + + private async Task EvaluatePolicyAsync() + { + if (string.IsNullOrWhiteSpace(Policy)) + return true; + + var principal = AuthState.ToClaimsPrincipal(); + var result = await AuthorizationService.AuthorizeAsync(principal, Policy); + + return result.Succeeded; + } + + private string BuildAuthKey() + { + return $"{Roles}|{Permissions}|{Policy}|{MatchAll}"; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs new file mode 100644 index 00000000..b64b221b --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Device/BrowserDeviceIdStorage.cs @@ -0,0 +1,36 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Infrastructure; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Device; + +public sealed class BrowserDeviceIdStorage : IDeviceIdStorage +{ + private const string Key = "udid"; + private readonly IClientStorage _storage; + + public BrowserDeviceIdStorage(IClientStorage storage) + { + _storage = storage; + } + + public async ValueTask LoadAsync(CancellationToken ct = default) + { + try + { + if (!await _storage.ExistsAsync(StorageScope.Local, Key)) + return null; + + return await _storage.GetAsync(StorageScope.Local, Key); + } + catch (TaskCanceledException) + { + return null; + } + } + + public ValueTask SaveAsync(string deviceId, CancellationToken ct = default) + { + return _storage.SetAsync(StorageScope.Local, Key, deviceId); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs new file mode 100644 index 00000000..3c0cb7aa --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/AssemblyExtensions.cs @@ -0,0 +1,21 @@ +๏ปฟusing System.Reflection; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +public static class UAuthAssemblies +{ + public static Assembly[] WithUltimateAuth(this IEnumerable? assemblies) + { + var authAssembly = typeof(UAuthBlazorClientMarker).Assembly; + + if (assemblies is null) + return new[] { authAssembly }; + + return assemblies.Append(authAssembly).DistinctBy(a => a.FullName).ToArray(); + } + + public static Assembly[] BlazorClient() + { + return new[] { typeof(UAuthBlazorClientMarker).Assembly }; + } +} \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..5a8f3d95 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,59 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Blazor.Device; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Extensions; + +/// +/// Provides extension methods for registering UltimateAuth client blazor services. +/// This layer method included core client capabilities with blazor components and storage. +/// This extension can safely be used together with AddUltimateAuthServer() in Blazor Server applications. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers UltimateAuth client services with Blazor adapter services. + /// This package depends on CodeBeam.UltimateAuth.Client and completes it by supplying platform-specific implementations. + /// This ensures that all required abstractions from the core client are properly wired for Blazor applications. + /// So, do not use "AddUltimateAuthClient" when "AddUltimateAuthClientBlazor" already specified. + /// + public static IServiceCollection AddUltimateAuthClientBlazor(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + services.AddUltimateAuthClient(configure); + + return services.AddUltimateAuthClientBlazorInternal(); + } + + /// + /// Internal shared registration pipeline for UltimateAuth client services. + /// This method does NOT register any server-side services. + /// + private static IServiceCollection AddUltimateAuthClientBlazorInternal(this IServiceCollection services) + { + services.TryAddScoped(); + + services.TryAddScoped(); + services.AddScoped(); + + services.AddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + services.AddAuthorizationCore(); + + return services; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BlazorReturnUrlProvider.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BlazorReturnUrlProvider.cs new file mode 100644 index 00000000..1e95cee8 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BlazorReturnUrlProvider.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Abstractions; +using Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +internal sealed class BlazorReturnUrlProvider : IReturnUrlProvider +{ + private readonly NavigationManager _nav; + + public BlazorReturnUrlProvider(NavigationManager nav) + { + _nav = nav; + } + + public string GetCurrentUrl() => _nav.Uri; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs new file mode 100644 index 00000000..017c43a5 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserClientStorage.cs @@ -0,0 +1,30 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +public sealed class BrowserClientStorage : IClientStorage +{ + private readonly IJSRuntime _js; + + public BrowserClientStorage(IJSRuntime js) + { + _js = js; + } + + public ValueTask SetAsync(StorageScope scope, string key, string value) + => _js.InvokeVoidAsync("uauth.storage.set", Scope(scope), key, value); + + public ValueTask GetAsync(StorageScope scope, string key) + => _js.InvokeAsync("uauth.storage.get", Scope(scope), key); + + public ValueTask RemoveAsync(StorageScope scope, string key) + => _js.InvokeVoidAsync("uauth.storage.remove", Scope(scope), key); + + public async ValueTask ExistsAsync(StorageScope scope, string key) + => await _js.InvokeAsync("uauth.storage.exists", Scope(scope), key); + + private static string Scope(StorageScope scope) + => scope == StorageScope.Local ? "local" : "session"; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs new file mode 100644 index 00000000..31d28d09 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/BrowserUAuthBridge.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Infrastructure; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +internal sealed class BrowserUAuthBridge : IBrowserUAuthBridge +{ + private readonly IJSRuntime _js; + + public BrowserUAuthBridge(IJSRuntime js) + { + _js = js; + } + + public ValueTask SetDeviceIdAsync(string deviceId) + { + return _js.InvokeVoidAsync("uauth.setDeviceId", deviceId); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/ClientDeviceProvider.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/ClientDeviceProvider.cs new file mode 100644 index 00000000..838ec578 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/ClientDeviceProvider.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.JSInterop; + +namespace CodeBeam.UltimateAuth.Client.Blazor; + +internal sealed class ClientDeviceProvider : IClientDeviceProvider +{ + private readonly IJSRuntime _js; + private readonly IDeviceIdProvider _deviceIdProvider; + private readonly IUserAgentParser _userAgentParser; + + public ClientDeviceProvider(IJSRuntime js, IDeviceIdProvider deviceIdProvider, IUserAgentParser userAgentParser) + { + _js = js; + _deviceIdProvider = deviceIdProvider; + _userAgentParser = userAgentParser; + } + + public async Task GetAsync() + { + var deviceId = await _deviceIdProvider.GetOrCreateAsync(); + var jsInfo = await _js.InvokeAsync("uauth.getDeviceInfo"); + var ua = jsInfo.UserAgent; + var parsed = _userAgentParser.Parse(ua); + + return DeviceContext.Create( + deviceId, + deviceType: parsed.DeviceType, + platform: parsed.Platform, + operatingSystem: parsed.OperatingSystem, + browser: parsed.Browser, + ipAddress: null + ); + } + + private sealed class ClientDeviceJsInfo + { + public string UserAgent { get; set; } = default!; + public string Platform { get; set; } = default!; + public string Language { get; set; } = default!; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs new file mode 100644 index 00000000..fb90ef57 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/SessionCoordinator.cs @@ -0,0 +1,104 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +// TODO: Add multi tab single refresh support +internal sealed class SessionCoordinator : ISessionCoordinator +{ + private readonly IUAuthClient _client; + private readonly NavigationManager _navigation; + private readonly UAuthClientOptions _options; + private readonly UAuthClientDiagnostics _diagnostics; + private readonly IClock _clock; + + private CancellationTokenSource? _cts; + + public event Action? ReauthRequired; + + public SessionCoordinator(IUAuthClient client, NavigationManager navigation, IOptions options, UAuthClientDiagnostics diagnostics, IClock clock) + { + _client = client; + _navigation = navigation; + _options = options.Value; + _diagnostics = diagnostics; + _clock = clock; + } + + public async Task StartAsync(CancellationToken cancellationToken = default) + { + if (!_options.AutoRefresh.Enabled) + return; + + if (_cts is not null) + return; + + _diagnostics.MarkStarted(); + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + _ = RunAsync(_cts.Token); + } + + private async Task RunAsync(CancellationToken ct) + { + var interval = _options.AutoRefresh.Interval ?? TimeSpan.FromMinutes(5); + + try + { + while (!ct.IsCancellationRequested) + { + await Task.Delay(interval, ct); + await TickAsync(); + + if (_diagnostics.IsTerminated) + return; + } + } + catch (OperationCanceledException) + { + } + } + + public Task StopAsync() + { + _diagnostics.MarkStopped(); + _cts?.Cancel(); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + } + + internal async Task TickAsync() + { + _diagnostics.MarkAutomaticRefresh(); + + var result = await _client.Flows.RefreshAsync(true); + + if (result.Outcome != RefreshOutcome.ReauthRequired) + return; + + if (result.Outcome == RefreshOutcome.ReauthRequired) + { + switch (_options.Reauth.Behavior) + { + case ReauthBehavior.Redirect: _navigation.NavigateTo(_options.Reauth.RedirectPath ?? _options.Endpoints.Login, forceLoad: true); + break; + + case ReauthBehavior.RaiseEvent: + ReauthRequired?.Invoke(); + break; + } + + _diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs new file mode 100644 index 00000000..7e1d5999 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthLoginPageDiscovery.cs @@ -0,0 +1,36 @@ +๏ปฟusing Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public static class UAuthLoginPageDiscovery +{ + private static string? _cached; + + public static string Resolve() + { + if (_cached != null) + return _cached; + + var assemblies = AppDomain.CurrentDomain.GetAssemblies(); + + var candidates = assemblies + .SelectMany(a => + { + try { return a.GetTypes(); } + catch { return Array.Empty(); } + }) + .Where(t => t.GetCustomAttributes(typeof(UAuthLoginPageAttribute), true).Any()) + .ToList(); + + if (candidates.Count == 0) + return _cached = "/login"; + + if (candidates.Count > 1) + throw new InvalidOperationException("Multiple [UAuthLoginPage] found. Make sure you only have one login page that attribute defined or define Navigation.LoginResolver explicitly."); + + var routeAttr = candidates[0].GetCustomAttributes(typeof(RouteAttribute), true).FirstOrDefault() as RouteAttribute; + + _cached = routeAttr?.Template ?? "/login"; + return _cached; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs new file mode 100644 index 00000000..bc66f3cb --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -0,0 +1,112 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; +using System.Net; + +// TODO: Add fluent helper API like RequiredOk +namespace CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; + +internal sealed class UAuthRequestClient : IUAuthRequestClient +{ + private readonly IJSRuntime _js; + IUAuthClientBootstrapper _bootstrapper; + private UAuthClientOptions _options; + + public UAuthRequestClient(IJSRuntime js, IUAuthClientBootstrapper bootstrapper, IOptions options) + { + _js = js; + _bootstrapper = bootstrapper; + _options = options.Value; + } + + public async Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + await _bootstrapper.EnsureStartedAsync(); + + await _js.InvokeVoidAsync("uauth.post", ct, new + { + url = endpoint, + mode = "navigate", + data = form, + clientProfile = _options.ClientProfile.ToString() + }); + } + + public async Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + await _bootstrapper.EnsureStartedAsync(); + + var result = await _js.InvokeAsync("uauth.post", ct, new + { + url = endpoint, + mode = "fetch", + data = form, + clientProfile = _options.ClientProfile.ToString() + }); + + if (result == null) + throw new UAuthProtocolException("Invalid error response format."); + + 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; + } + + public async Task SendJsonAsync(string endpoint, object? payload = default, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + await _bootstrapper.EnsureStartedAsync(); + + var result = await _js.InvokeAsync("uauth.postJson", ct, new + { + url = endpoint, + payload = payload, + clientProfile = _options.ClientProfile.ToString() + }); + + if (result == null) + throw new UAuthProtocolException("Invalid error response format."); + + 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; + } + + public async Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default) + { + await _bootstrapper.EnsureStartedAsync(); + + var response = await _js.InvokeAsync( + "uauth.tryAndCommit", + ct, + new + { + tryUrl = tryEndpoint, + commitUrl = commitEndpoint, + data = request, + clientProfile = _options.ClientProfile.ToString() + }); + + if (response is null) + throw new UAuthProtocolException("Invalid tryAndCommit response."); + + return response; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/README.md b/src/client/CodeBeam.UltimateAuth.Client.Blazor/README.md new file mode 100644 index 00000000..7896f81a --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/README.md @@ -0,0 +1,12 @@ +๏ปฟ# UltimateAuth Blazor Client + +Client blazor integration for UltimateAuth. + +## Features + +- Authentication state integration +- Session handling +- Token flows +- PKCE support +- Blazor components +- JS Interop unified transport layer diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs new file mode 100644 index 00000000..d7174ce1 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Runtime/UAuthBlazorClientMarker.cs @@ -0,0 +1,5 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Blazor; + +public class UAuthBlazorClientMarker +{ +} diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js new file mode 100644 index 00000000..a1eddceb --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/TScripts/uauth.js @@ -0,0 +1,244 @@ +๏ปฟwindow.uauth = window.uauth || {}; + +window.uauth.storage = { + set: function (scope, key, value) { + const storage = scope === "local" + ? window.localStorage + : window.sessionStorage; + + storage.setItem(key, value); + }, + + get: function (scope, key) { + const storage = scope === "local" + ? window.localStorage + : window.sessionStorage; + + return storage.getItem(key); + }, + + remove: function (scope, key) { + const storage = scope === "local" + ? window.localStorage + : window.sessionStorage; + + storage.removeItem(key); + }, + + exists: function (scope, key) { + const storage = scope === "local" + ? window.localStorage + : window.sessionStorage; + + return storage.getItem(key) !== null; + } +}; + +window.uauth.submitForm = function (form) { + if (!form) + return; + + if (!window.uauth.deviceId) { + throw new Error("UAuth deviceId is not initialized."); + } + + let udid = form.querySelector("input[name='__uauth_device']"); + if (!udid) { + udid = document.createElement("input"); + udid.type = "hidden"; + udid.name = "__uauth_device"; + form.appendChild(udid); + } + udid.value = window.uauth.deviceId; + + form.submit(); +}; + +window.uauth.tryAndCommit = async function (options) { + const { tryUrl, commitUrl, data, clientProfile } = options; + + const tryResponse = await window.uauth.postJson({ + url: tryUrl, + payload: data, + clientProfile: clientProfile + }); + + let result = tryResponse?.body; + + if (!result) { + result = {}; + } + + const normalized = { + success: result.success ?? false, + reason: result.reason ?? null, + remainingAttempts: result.remainingAttempts ?? null, + lockoutUntilUtc: result.lockoutUntilUtc ?? null, + requiresMfa: result.requiresMfa ?? false, + retryWithNewPkce: result.retryWithNewPkce ?? false + }; + + if (normalized.success) { + const form = document.createElement("form"); + form.method = "POST"; + form.action = commitUrl; + + for (const key in data) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = data[key] ?? ""; + form.appendChild(input); + } + + const cp = document.createElement("input"); + cp.type = "hidden"; + cp.name = "__uauth_client_profile"; + cp.value = clientProfile ?? ""; + form.appendChild(cp); + + const udid = document.createElement("input"); + udid.type = "hidden"; + udid.name = "__uauth_device"; + udid.value = window.uauth.deviceId; + form.appendChild(udid); + + document.body.appendChild(form); + form.submit(); + } + + return normalized; +}; + +window.uauth.post = async function (options) { + const { + url, + mode, + data, + clientProfile + } = options; + + if (mode === "navigate") { + const form = document.createElement("form"); + form.method = "POST"; + form.action = url; + + const cp = document.createElement("input"); + cp.type = "hidden"; + cp.name = "__uauth_client_profile"; + cp.value = clientProfile ?? ""; + form.appendChild(cp); + + const udid = document.createElement("input"); + udid.type = "hidden"; + udid.name = "__uauth_device"; + udid.value = window.uauth.deviceId; + form.appendChild(udid); + + if (data) { + for (const key in data) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = key; + input.value = data[key]; + form.appendChild(input); + } + } + + document.body.appendChild(form); + form.submit(); + return null; + } + + let body = null; + if (!window.uauth.deviceId) { + throw new Error("UAuth deviceId is not initialized."); + } + const headers = { + "X-UDID": window.uauth.deviceId, + "X-UAuth-ClientProfile": clientProfile, + "X-Requested-With": "UAuth" + }; + + if (data) { + body = new URLSearchParams(); + for (const key in data) { + body.append(key, data[key]); + } + + headers["Content-Type"] = "application/x-www-form-urlencoded"; + } + + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: body + }); + + let responseBody = null; + try { + responseBody = await response.json(); + } catch { + responseBody = null; + } + + return { + ok: response.ok, + status: response.status, + refreshOutcome: response.headers.get("X-UAuth-Refresh"), + body: responseBody + }; +}; + +window.uauth.postJson = async function (options) { + const { + url, + payload, + clientProfile + } = options; + + if (!window.uauth.deviceId) { + throw new Error("UAuth deviceId is not initialized."); + } + + const headers = { + "Content-Type": "application/json", + "X-UDID": window.uauth.deviceId, + "X-UAuth-ClientProfile": clientProfile ?? "", + "X-Requested-With": "UAuth" + }; + + const response = await fetch(url, { + method: "POST", + credentials: "include", + headers: headers, + body: payload ? JSON.stringify(payload) : null + }); + + let responseBody = null; + try { + responseBody = await response.json(); + } catch { + responseBody = null; + } + + return { + ok: response.ok, + status: response.status, + refreshOutcome: response.headers.get("X-UAuth-Refresh"), + body: responseBody + }; +}; + +window.uauth.setDeviceId = function (value) { + window.uauth.deviceId = value; +}; + +window.uauth.getDeviceInfo = function () { + return { + userAgent: navigator.userAgent, + platform: navigator.platform, + language: navigator.language + }; +}; diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/_Imports.razor b/src/client/CodeBeam.UltimateAuth.Client.Blazor/_Imports.razor new file mode 100644 index 00000000..d2138d49 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/_Imports.razor @@ -0,0 +1,6 @@ +๏ปฟ@using Microsoft.JSInterop + +@using CodeBeam.UltimateAuth.Client +@using CodeBeam.UltimateAuth.Client.Abstractions +@using CodeBeam.UltimateAuth.Client.Infrastructure +@using CodeBeam.UltimateAuth.Client.Blazor diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/logo.png b/src/client/CodeBeam.UltimateAuth.Client.Blazor/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/client/CodeBeam.UltimateAuth.Client.Blazor/logo.png differ diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js new file mode 100644 index 00000000..28aff8b9 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/wwwroot/uauth.min.js @@ -0,0 +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){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 diff --git a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj new file mode 100644 index 00000000..f6abad23 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/CodeBeam.UltimateAuth.Client.JsMinifier.csproj @@ -0,0 +1,13 @@ +๏ปฟ + + + net10.0 + Exe + false + + + + + + + diff --git a/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs new file mode 100644 index 00000000..a42d227c --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client.JsMinifier/Program.cs @@ -0,0 +1,35 @@ +๏ปฟusing NUglify; +using System.Text; + +if (args.Length < 2) +{ + Console.Error.WriteLine("Missing input/output arguments."); + Environment.Exit(1); +} + +var input = args[0]; +var output = args[1]; + +Console.WriteLine($"Minifying: {input}"); +Console.WriteLine($"Output : {output}"); + +if (!File.Exists(input)) +{ + Console.Error.WriteLine($"Input file not found: {input}"); + Environment.Exit(1); +} + +var js = File.ReadAllText(input, Encoding.UTF8); +var result = Uglify.Js(js); + +if (result.HasErrors) +{ + foreach (var error in result.Errors) + Console.Error.WriteLine(error); + + Environment.Exit(1); +} + +File.WriteAllText(output, result.Code!, Encoding.UTF8); + +Console.WriteLine("โœ” uauth.min.js generated successfully"); diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs new file mode 100644 index 00000000..d307fbdc --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientDeviceProvider.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Abstractions; + +public interface IClientDeviceProvider +{ + Task GetAsync(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs new file mode 100644 index 00000000..9f605d26 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IClientStorage.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IClientStorage +{ + ValueTask SetAsync(StorageScope scope, string key, string value); + ValueTask GetAsync(StorageScope scope, string key); + ValueTask RemoveAsync(StorageScope scope, string key); + ValueTask ExistsAsync(StorageScope scope, string key); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs new file mode 100644 index 00000000..f5eddd8e --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/IReturnUrlProvider.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Abstractions; + +public interface IReturnUrlProvider +{ + string GetCurrentUrl(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs new file mode 100644 index 00000000..ce1781aa --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Abstractions/ISessionCoordinator.cs @@ -0,0 +1,17 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Abstractions; + +public interface ISessionCoordinator : IAsyncDisposable +{ + /// + /// Starts session coordination. + /// Should be idempotent. + /// + Task StartAsync(CancellationToken cancellationToken = default); + + /// + /// Stops coordination (optional). + /// + Task StopAsync(); + + event Action? ReauthRequired; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs b/src/client/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs new file mode 100644 index 00000000..49c98ad3 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AssemblyVisibility.cs @@ -0,0 +1,4 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Client.Blazor")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs new file mode 100644 index 00000000..c5c28a5c --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/IUAuthStateManager.cs @@ -0,0 +1,40 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +/// +/// Orchestrates the lifecycle of UAuthState. +/// This is the single authority responsible for keeping +/// client-side authentication state in sync with the server. +/// +public interface IUAuthStateManager +{ + /// + /// Current in-memory authentication state. + /// + UAuthState State { get; } + + /// + /// Ensures the authentication state is valid. + /// May call server validate/refresh if needed. + /// + Task EnsureAsync(bool force = false, CancellationToken ct = default); + + /// + /// Called after a successful login. + /// + Task OnLoginAsync(); + + /// + /// Called after logout. + /// + Task OnLogoutAsync(); + + /// + /// Forces state to be cleared and re-validation required. + /// + void MarkStale(); + + /// + /// Removes all authentication state information managed by the implementation. + /// + void Clear(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs new file mode 100644 index 00000000..cbc32fbe --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthState.cs @@ -0,0 +1,165 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Client; + +/// +/// Represents the client-side authentication snapshot for UltimateAuth. +/// +/// This is a lightweight, memory-only view of the current authentication state. +/// It is not a security boundary and must always be validated server-side. +/// +public sealed class UAuthState +{ + private UAuthState() { } + + public AuthIdentitySnapshot? Identity { get; private set; } + public ClaimsSnapshot Claims { get; private set; } = ClaimsSnapshot.Empty; + + public DateTimeOffset? LastValidatedAt { get; private set; } + + /// + /// Indicates whether the snapshot may be stale (e.g. after navigation, reload, or time-based heuristics). + /// + public bool IsStale { get; private set; } + + + public event Action? Changed; + internal Action? RequestRender; + + public bool IsAuthenticated => Identity is not null; + public bool NeedsValidation => IsAuthenticated && IsStale; + + public static UAuthState Anonymous() => new(); + + internal void ApplySnapshot(AuthStateSnapshot snapshot, DateTimeOffset validatedAt) + { + Identity = snapshot.Identity; + Claims = snapshot.Claims; + + _compiledPermissions = new CompiledPermissionSet(Claims.Permissions.Select(Permission.From)); + + IsStale = false; + LastValidatedAt = validatedAt; + + Changed?.Invoke(UAuthStateChangeReason.Authenticated); + } + + internal void UpdateProfile(UpdateProfileRequest req) + { + if (Identity is null) + return; + + Identity = Identity with + { + DisplayName = req.DisplayName ?? Identity.DisplayName + }; + + Changed?.Invoke(UAuthStateChangeReason.Patched); + } + + internal void UpdateUserStatus(ChangeUserStatusSelfRequest req) + { + if (Identity is null) + return; + + Identity = Identity with + { + UserStatus = UserStatusMapper.ToUserStatus(req.NewStatus) + }; + + Changed?.Invoke(UAuthStateChangeReason.Patched); + } + + internal void MarkValidated(DateTimeOffset now) + { + if (!IsAuthenticated) + return; + + LastValidatedAt = now; + IsStale = false; + + Changed?.Invoke(UAuthStateChangeReason.Validated); + } + + internal void MarkStale() + { + if (!IsAuthenticated) + return; + + IsStale = true; + Changed?.Invoke(UAuthStateChangeReason.MarkedStale); + } + + public void Touch(bool updateState = true) + { + if (updateState) + { + IsStale = true; + } + + RequestRender?.Invoke(); + } + + internal void Clear() + { + Identity = null; + Claims = ClaimsSnapshot.Empty; + + IsStale = false; + + Changed?.Invoke(UAuthStateChangeReason.Cleared); + } + + public bool IsInRole(string role) => IsAuthenticated && Claims.IsInRole(role); + + private CompiledPermissionSet? _compiledPermissions; + public bool HasPermission(string permission) + { + if (!IsAuthenticated) + return false; + + if (Claims.HasPermission(permission)) + return true; + + return _compiledPermissions?.IsAllowed(permission) == true; + } + + public bool HasAnyPermission(params string[] permissions) + { + foreach (var perm in permissions) + { + if (HasPermission(perm)) + return true; + } + + return false; + } + + public bool HasClaim(string type, string value) => IsAuthenticated && Claims.HasValue(type, value); + + public string? GetClaim(string type) => IsAuthenticated ? Claims.Get(type) : null; + + /// + /// Creates a ClaimsPrincipal view for ASP.NET / Blazor integration. + /// + public ClaimsPrincipal ToClaimsPrincipal(string authenticationType = UAuthConstants.SchemeDefaults.GlobalScheme) + { + if (!IsAuthenticated || Identity is null) + return new ClaimsPrincipal(new ClaimsIdentity()); + + var claims = Claims.ToClaims().ToList(); + claims.Add(new Claim(ClaimTypes.NameIdentifier, Identity.UserKey.Value)); + + if (!string.IsNullOrWhiteSpace(Identity.PrimaryUserName)) + claims.Add(new Claim(ClaimTypes.Name, Identity.PrimaryUserName)); + + var identity = new ClaimsIdentity(claims, authenticationType, ClaimTypes.Name, ClaimTypes.Role); + return new ClaimsPrincipal(identity); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs new file mode 100644 index 00000000..881df3f2 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateChangeReason.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateChangeReason +{ + Authenticated, + Validated, + MarkedStale, + Cleared, + Touched, + Patched +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs new file mode 100644 index 00000000..6367d6a1 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEvent.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateEvent +{ + ValidationCalled, + IdentifiersChanged, + UserStatusChanged, + ProfileChanged, + CredentialsChanged, + CredentialsChangedSelf, + AuthorizationChanged, + SessionRevoked, + UserDeleted, + LogoutVariant +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs new file mode 100644 index 00000000..7ec39119 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventArgs.cs @@ -0,0 +1,16 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +public abstract record UAuthStateEventArgs( + UAuthStateEvent Type, + UAuthStateEventHandlingMode RefreshMode); + +public sealed record UAuthStateEventArgs( + UAuthStateEvent Type, + UAuthStateEventHandlingMode RefreshMode, + TPayload Payload) + : UAuthStateEventArgs(Type, RefreshMode); + +public sealed record UAuthStateEventArgsEmpty( + UAuthStateEvent Type, + UAuthStateEventHandlingMode RefreshMode) + : UAuthStateEventArgs(Type, RefreshMode); diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs new file mode 100644 index 00000000..173f6526 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateEventHandlingMode.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +public enum UAuthStateEventHandlingMode +{ + Patch, + Validate, + None +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs new file mode 100644 index 00000000..26399d5d --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/AuthState/UAuthStateManager.cs @@ -0,0 +1,145 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Authentication; + +internal sealed class UAuthStateManager : IUAuthStateManager, IDisposable +{ + private Task? _ensureTask; + private readonly object _ensureLock = new(); + + private readonly IUAuthClient _client; + private readonly IUAuthClientEvents _events; + private readonly IClock _clock; + + public UAuthState State { get; } = UAuthState.Anonymous(); + + public UAuthStateManager(IUAuthClient client, IUAuthClientEvents events, IClock clock) + { + _client = client; + _events = events; + _clock = clock; + + _events.StateChanged += HandleStateEvent; + } + + public Task EnsureAsync(bool force = false, CancellationToken ct = default) + { + if (!force && State.IsAuthenticated && !State.IsStale) + return Task.CompletedTask; + + lock (_ensureLock) + { + if (_ensureTask != null) + return _ensureTask; + + _ensureTask = EnsureInternalAsync(ct); + return _ensureTask; + } + } + + private async Task EnsureInternalAsync(CancellationToken ct) + { + try + { + var result = await _client.Flows.ValidateAsync(); + + if (!result.IsValid || result.Snapshot == null) + { + if (State.IsAuthenticated) + State.MarkStale(); + else + State.Clear(); + + return; + } + + State.ApplySnapshot(result.Snapshot, _clock.UtcNow); + } + finally + { + lock (_ensureLock) + { + _ensureTask = null; + } + } + } + + private async Task HandleStateEvent(UAuthStateEventArgs args) + { + if (args.RefreshMode == UAuthStateEventHandlingMode.None) + { + return; + } + + switch (args.Type) + { + case UAuthStateEvent.SessionRevoked: + case UAuthStateEvent.CredentialsChanged: + case UAuthStateEvent.UserDeleted: + case UAuthStateEvent.LogoutVariant: + State.Clear(); + return; + } + + switch (args) + { + case UAuthStateEventArgs profile: + State.UpdateProfile(profile.Payload); + return; + + case UAuthStateEventArgs profile: + State.UpdateUserStatus(profile.Payload); + return; + } + + switch (args.RefreshMode) + { + case UAuthStateEventHandlingMode.Validate: + await EnsureAsync(true); + return; + + case UAuthStateEventHandlingMode.Patch: + if (args.Type == UAuthStateEvent.ValidationCalled) + { + State.MarkValidated(_clock.UtcNow); + return; + } + break; + default: + break; + } + + State.Touch(true); + } + + public Task OnLoginAsync() + { + State.MarkStale(); + return Task.CompletedTask; + } + + public Task OnLogoutAsync() + { + State.Clear(); + return Task.CompletedTask; + } + + public void MarkStale() + { + State.MarkStale(); + } + + public void Clear() + { + State.Clear(); + } + + public void Dispose() + { + _events.StateChanged -= HandleStateEvent; + } + + public bool NeedsValidation => !State.IsAuthenticated || State.IsStale; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj new file mode 100644 index 00000000..b519d1ec --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/CodeBeam.UltimateAuth.Client.csproj @@ -0,0 +1,28 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Client + + Core client engine for UltimateAuth. Provides platform-agnostic authentication features. This package does NOT include transport, storage or UI integration. For complete experience, use a platform adapter such as CodeBeam.UltimateAuth.Client.Blazor + authentication;authorization;identity;aspnetcore;auth;oauth;pkce;jwt;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + + diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs new file mode 100644 index 00000000..3b079766 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/CoordinatorTerminationReason.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Contracts; + +public enum CoordinatorTerminationReason +{ + None = 0, + ReauthRequired = 1 +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs new file mode 100644 index 00000000..f04f078b --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/RefreshResult.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public sealed record RefreshResult +{ + public bool IsSuccess { get; init; } + public int Status { get; init; } + public RefreshOutcome Outcome { get; init; } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs new file mode 100644 index 00000000..322f397c --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/StorageScope.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Contracts; + +public enum StorageScope +{ + Session, + Local +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs new file mode 100644 index 00000000..6a13be2f --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/TenantTransport.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Contracts; + +public enum TenantTransport +{ + None, + Header, + Route +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs new file mode 100644 index 00000000..f7f1cbba --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthRenderMode.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +public enum UAuthRenderMode +{ + Manual = 0, + Reactive = 1 +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs new file mode 100644 index 00000000..4875462b --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthSubmitMode.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +public enum UAuthSubmitMode +{ + DirectCommit = 0, + TryOnly = 10, + TryAndCommit = 20, +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs new file mode 100644 index 00000000..1fd9fc47 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Contracts/UAuthTransportResult.cs @@ -0,0 +1,19 @@ +๏ปฟusing System.Text.Json; +using System.Text.Json.Serialization; + +namespace CodeBeam.UltimateAuth.Client.Contracts; + +public sealed class UAuthTransportResult +{ + [JsonPropertyName("ok")] + public bool Ok { get; init; } + + [JsonPropertyName("status")] + public int Status { get; init; } + + [JsonPropertyName("refreshOutcome")] + public string? RefreshOutcome { get; init; } + + [JsonPropertyName("body")] + public JsonElement? Body { get; init; } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs new file mode 100644 index 00000000..033d82f0 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdGenerator.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Device; + +public interface IDeviceIdGenerator +{ + DeviceId Generate(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs new file mode 100644 index 00000000..a9be9fdd --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdProvider.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client; + +public interface IDeviceIdProvider +{ + ValueTask GetOrCreateAsync(CancellationToken ct = default); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs new file mode 100644 index 00000000..c91457d3 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/IDeviceIdStorage.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Device; + +public interface IDeviceIdStorage +{ + ValueTask LoadAsync(CancellationToken ct = default); + ValueTask SaveAsync(string deviceId, CancellationToken ct = default); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs new file mode 100644 index 00000000..8c1001b1 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdGenerator.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Core.Domain; +using System.Security.Cryptography; + +namespace CodeBeam.UltimateAuth.Client.Devices; + +internal sealed class UAuthDeviceIdGenerator : IDeviceIdGenerator +{ + public DeviceId Generate() + { + Span buffer = stackalloc byte[32]; + RandomNumberGenerator.Fill(buffer); + + var raw = Convert.ToBase64String(buffer); + return DeviceId.Create(raw); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs new file mode 100644 index 00000000..22cb4d31 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Device/UAuthDeviceIdProvider.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client; + +internal sealed class UAuthDeviceIdProvider : IDeviceIdProvider +{ + private readonly IDeviceIdStorage _storage; + private readonly IDeviceIdGenerator _generator; + private readonly SemaphoreSlim _gate = new(1, 1); + private DeviceId? _cached; + + public UAuthDeviceIdProvider(IDeviceIdStorage storage, IDeviceIdGenerator generator) + { + _storage = storage; + _generator = generator; + } + + public async ValueTask GetOrCreateAsync(CancellationToken ct = default) + { + if (_cached is not null) + return _cached.Value; + + await _gate.WaitAsync(ct); + try + { + var raw = await _storage.LoadAsync(ct); + + if (!string.IsNullOrWhiteSpace(raw)) + { + _cached = DeviceId.Create(raw); + return _cached.Value; + } + + var generated = _generator.Generate(); + await _storage.SaveAsync(generated.Value, ct); + + _cached = generated; + return generated; + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs new file mode 100644 index 00000000..eb21d03a --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Diagnostics/UAuthClientDiagnostics.cs @@ -0,0 +1,111 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Diagnostics; + +public sealed class UAuthClientDiagnostics +{ + private int _terminatedCount; + + public event Action? Changed; + + public DateTimeOffset? StartedAt { get; private set; } + public DateTimeOffset? StoppedAt { get; private set; } + public DateTimeOffset? TerminatedAt { get; private set; } + + public bool IsRunning => StartedAt is not null && !IsStopped && !IsTerminated; + public bool IsStopped => StoppedAt is not null; + public bool IsTerminated { get; private set; } + + public CoordinatorTerminationReason? TerminationReason { get; private set; } + public int TerminatedCount => _terminatedCount; + + public int StartCount { get; private set; } + public int StopCount { get; private set; } + + public int RefreshAttemptCount { get; private set; } + public int ManualRefreshCount { get; private set; } + public int AutomaticRefreshCount { get; private set; } + + public int RefreshTouchedCount { get; private set; } + public int RefreshRotatedCount { get; private set; } + public int RefreshNoOpCount { get; private set; } + public int RefreshReauthRequiredCount { get; private set; } + public int RefreshSuccessCount { get; private set; } + + public TimeSpan? RunningDuration => + StartedAt is null + ? null + : (IsStopped || IsTerminated + ? (StoppedAt ?? TerminatedAt) - StartedAt + : DateTimeOffset.UtcNow - StartedAt); + + internal void MarkStarted() + { + StartedAt = DateTimeOffset.UtcNow; + StoppedAt = null; + IsTerminated = false; + TerminationReason = null; + + StartCount++; + Changed?.Invoke(); + } + + internal void MarkStopped() + { + StoppedAt = DateTimeOffset.UtcNow; + StopCount++; + Changed?.Invoke(); + } + + internal void MarkManualRefresh() + { + RefreshAttemptCount++; + ManualRefreshCount++; + Changed?.Invoke(); + } + + internal void MarkAutomaticRefresh() + { + RefreshAttemptCount++; + AutomaticRefreshCount++; + Changed?.Invoke(); + } + internal void MarkRefreshTouched() + { + RefreshTouchedCount++; + Changed?.Invoke(); + } + + internal void MarkRefreshRotated() + { + RefreshRotatedCount++; + Changed?.Invoke(); + } + + internal void MarkRefreshNoOp() + { + RefreshNoOpCount++; + Changed?.Invoke(); + } + + internal void MarkRefreshReauthRequired() + { + RefreshReauthRequiredCount++; + Changed?.Invoke(); + } + + internal void MarkRefreshSuccess() + { + RefreshSuccessCount++; + Changed?.Invoke(); + } + + internal void MarkTerminated(CoordinatorTerminationReason reason) + { + IsTerminated = true; + TerminatedAt = DateTimeOffset.UtcNow; + TerminationReason = reason; + Interlocked.Increment(ref _terminatedCount); + Changed?.Invoke(); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs new file mode 100644 index 00000000..27ecb2cd --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthClientException.cs @@ -0,0 +1,14 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Errors; + +using CodeBeam.UltimateAuth.Core.Errors; + +public abstract class UAuthClientException : UAuthException +{ + protected UAuthClientException(string code, string message) : base(code, message) + { + } + + protected UAuthClientException(string code, string message, Exception? inner) : base(code, message, inner) + { + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs new file mode 100644 index 00000000..7b7146a0 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthProtocolException.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Errors; + +public sealed class UAuthProtocolException : UAuthClientException +{ + public UAuthProtocolException(string message) : base("protocol_error", message) + { + } + + public UAuthProtocolException(string message, Exception? inner) : base("protocol_error", message, inner) + { + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs new file mode 100644 index 00000000..b41c0f6a --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Errors/UAuthTransportException.cs @@ -0,0 +1,26 @@ +๏ปฟusing System.Net; + +namespace CodeBeam.UltimateAuth.Client.Errors; + +public sealed class UAuthTransportException : UAuthClientException +{ + public HttpStatusCode? StatusCode { get; } + + public UAuthTransportException(string message) : base("transport_error", message) + { + } + + public UAuthTransportException(string message, Exception? inner) : base("transport_error", message, inner) + { + } + + public UAuthTransportException(string message, HttpStatusCode statusCode) : base("transport_error", message) + { + StatusCode = statusCode; + } + + public UAuthTransportException(string message, HttpStatusCode statusCode, Exception? inner) : base("transport_error", message, inner) + { + StatusCode = statusCode; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs b/src/client/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs new file mode 100644 index 00000000..dfe52ad2 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Events/IUAuthClientEvents.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Events; + +public interface IUAuthClientEvents +{ + event Func? StateChanged; + Task PublishAsync(UAuthStateEventArgs args); + Task PublishAsync(UAuthStateEventArgs args); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs b/src/client/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs new file mode 100644 index 00000000..395126c3 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Events/UAuthClientEvents.cs @@ -0,0 +1,21 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Events; + +internal sealed class UAuthClientEvents : IUAuthClientEvents +{ + public event Func? StateChanged; + + public Task PublishAsync(UAuthStateEventArgs args) + => PublishAsync((UAuthStateEventArgs)args); + + public async Task PublishAsync(UAuthStateEventArgs args) + { + var handlers = StateChanged; + if (handlers == null) + return; + + foreach (var handler in handlers.GetInvocationList()) + { + await ((Func)handler)(args); + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs new file mode 100644 index 00000000..8e9defcb --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/LoginRequestFormExtensions.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Extensions; + +internal static class LoginRequestFormExtensions +{ + public static IDictionary ToDictionary(this LoginRequest request) + => new Dictionary + { + ["Identifier"] = request.Identifier, + ["Secret"] = request.Secret + }; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs b/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..42431a3b --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,126 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Device; +using CodeBeam.UltimateAuth.Client.Devices; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Extensions; + +/// +/// Provides extension methods for registering UltimateAuth client services. +/// +/// This layer is responsible for: +/// - Client-side authentication actions (login, logout, refresh, reauth) +/// - Browser-based POST infrastructure (JS form submit) +/// - Endpoint configuration for auth mutations +/// +/// This extension can safely be used together with AddUltimateAuthServer() +/// in Blazor Server applications. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers core UltimateAuth client services. + /// + /// This package contains the platform-agnostic authentication engine: + /// - Authentication flows (login, logout, refresh, PKCE) + /// - Client state management + /// - Domain-level services (User, Session, Credential, Authorization) + /// + /// + /// IMPORTANT: + /// This package does NOT include any platform-specific implementations such as: + /// - HTTP / JS transport + /// - Storage (browser, mobile, etc.) + /// - UI integrations + /// + /// To use this in an application, you must install a client adapter package + /// such as: + /// - CodeBeam.UltimateAuth.Client.Blazor + /// - (future) CodeBeam.UltimateAuth.Client.Maui + /// + /// These adapter packages provide concrete implementations for: + /// - Request transport + /// - Storage + /// - Device identification + /// - Navigation / UI integration + /// + public static IServiceCollection AddUltimateAuthClient(this IServiceCollection services, Action? configure = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddUltimateAuth(); + services.TryAddSingleton(); + + services.AddOptions() + .Configure((options, marker) => + { + if (configure != null) + { + marker.MarkConfigured(); + configure(options); + } + }) + .BindConfiguration("UltimateAuth:Client"); + + return services.AddUltimateAuthClientInternal(); + } + + /// + /// Internal shared registration pipeline for UltimateAuth client services. + /// + /// This method registers: + /// - Client infrastructure + /// - Public client abstractions + /// + /// NOTE: + /// This method does NOT register any server-side services. + /// + private static IServiceCollection AddUltimateAuthClientInternal(this IServiceCollection services) + { + services.AddScoped(); + + services.AddSingleton, UAuthClientOptionsValidator>(); + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); + + services.AddSingleton(); + services.AddSingleton, UAuthClientOptionsPostConfigure>(); + services.TryAddSingleton(); + services.AddSingleton(); + + services.PostConfigure(o => + { + o.AutoRefresh.Interval ??= TimeSpan.FromMinutes(5); + }); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + services.AddScoped(); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + + return services; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs new file mode 100644 index 00000000..38d5a75a --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientClock.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class ClientClock : IClock +{ + public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs new file mode 100644 index 00000000..6b8138c8 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/ClientLoginCapabilities.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class ClientLoginCapabilities +{ + public static bool CanPostCredentials(UAuthClientProfile profile) + => profile switch + { + UAuthClientProfile.BlazorServer => true, + UAuthClientProfile.UAuthHub => true, + _ => false + }; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs new file mode 100644 index 00000000..2098052a --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IBrowserUAuthBridge.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IBrowserUAuthBridge +{ + ValueTask SetDeviceIdAsync(string deviceId); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs new file mode 100644 index 00000000..13b5b154 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthClientBootstrapper.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IUAuthClientBootstrapper +{ + Task EnsureStartedAsync(CancellationToken ct = default); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs new file mode 100644 index 00000000..98618306 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/IUAuthRequestClient.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public interface IUAuthRequestClient +{ + Task NavigateAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + + Task SendFormAsync(string endpoint, IDictionary? form = null, CancellationToken ct = default); + + Task SendJsonAsync(string endpoint, object? payload = null, CancellationToken ct = default); + + Task TryAndCommitAsync(string tryEndpoint, string commitEndpoint, object request, CancellationToken ct = default); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs new file mode 100644 index 00000000..002a7c27 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCapabilities.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpHubCapabilities : IHubCapabilities +{ + public bool SupportsPkce => false; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs new file mode 100644 index 00000000..69c2b989 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubCredentialResolver.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpHubCredentialResolver : IHubCredentialResolver +{ + public Task ResolveAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs new file mode 100644 index 00000000..9cdfa747 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpHubFlowReader.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpHubFlowReader : IHubFlowReader +{ + public Task GetStateAsync(HubSessionId sessionId, CancellationToken ct = default) => Task.FromResult(null); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs new file mode 100644 index 00000000..800515b8 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/NoOpSessionCoordinator.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Abstractions; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class NoOpSessionCoordinator : ISessionCoordinator +{ +#pragma warning disable CS0067 + public event Action? ReauthRequired; +#pragma warning restore CS0067 + + public Task StartAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task StopAsync() => Task.CompletedTask; + public ValueTask DisposeAsync() => ValueTask.CompletedTask; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs new file mode 100644 index 00000000..261cbd6d --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/RefreshOutcomeParser.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class RefreshOutcomeParser +{ + public static RefreshOutcome Parse(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return RefreshOutcome.Success; + + return value switch + { + "no-op" => RefreshOutcome.NoOp, + "touched" => RefreshOutcome.Touched, + "rotated" => RefreshOutcome.Rotated, + "reauth-required" => RefreshOutcome.ReauthRequired, + _ => RefreshOutcome.Success + }; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs new file mode 100644 index 00000000..c70a0375 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthClientBootstrapper.cs @@ -0,0 +1,41 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Infrastructure; + +// TODO: Add device id auto creation for MVC, this is only for blazor. +internal sealed class UAuthClientBootstrapper : IUAuthClientBootstrapper +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private bool _started; + + private readonly IDeviceIdProvider _deviceIdProvider; + private readonly IBrowserUAuthBridge _browser; + + public bool IsStarted => _started; + + public UAuthClientBootstrapper(IDeviceIdProvider deviceIdProvider, IBrowserUAuthBridge browser) + { + _deviceIdProvider = deviceIdProvider; + _browser = browser; + } + + public async Task EnsureStartedAsync(CancellationToken ct = default) + { + if (_started) + return; + + await _gate.WaitAsync(ct); + try + { + if (_started) + return; + + var deviceId = await _deviceIdProvider.GetOrCreateAsync(); + await _browser.SetDeviceIdAsync(deviceId.Value); + + _started = true; + } + finally + { + _gate.Release(); + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs new file mode 100644 index 00000000..d42e1275 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthLoginPageAttribute.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client; + +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false)] +public sealed class UAuthLoginPageAttribute : Attribute +{ +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs new file mode 100644 index 00000000..2156fafa --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthResultMapper.cs @@ -0,0 +1,82 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Core.Contracts; +using System.Net; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal static class UAuthResultMapper +{ + private static readonly JsonSerializerOptions _jsonOptions = + new() { PropertyNameCaseInsensitive = true }; + + public static UAuthResult FromJson(UAuthTransportResult raw) + { + EnsureTransport(raw); + + if (raw.Status >= 400 && raw.Status < 500) + { + var problem = TryDeserializeProblem(raw); + + return new UAuthResult + { + IsSuccess = false, + Status = raw.Status, + Problem = problem + }; + } + + if (raw.Body is null) + { + return new UAuthResult + { + IsSuccess = true, + Status = raw.Status, + Value = default + }; + } + + try + { + var value = raw.Body.Value.Deserialize(_jsonOptions); + + return new UAuthResult + { + IsSuccess = true, + Status = raw.Status, + Value = value + }; + } + catch (JsonException ex) + { + throw new UAuthProtocolException("Invalid response format.", ex); + } + } + + public static UAuthResult From(UAuthTransportResult raw) => FromJson(raw); + + private static void EnsureTransport(UAuthTransportResult raw) + { + if (raw.Status == 0) + throw new UAuthTransportException("Network error."); + + if (raw.Status >= 500) + throw new UAuthTransportException($"Server error {raw.Status}", (HttpStatusCode)raw.Status); + } + + private static UAuthProblem? TryDeserializeProblem(UAuthTransportResult raw) + { + if (raw.Body is null) + return null; + + try + { + return raw.Body.Value.Deserialize(_jsonOptions); + } + catch + { + return null; + } + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs new file mode 100644 index 00000000..717c9378 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Infrastructure/UAuthUrlBuilder.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +public static class UAuthUrlBuilder +{ + public static string Build(string authority, string relativePath, UAuthClientMultiTenantOptions tenant) + { + var baseAuthority = authority.TrimEnd('/'); + + if (tenant.Enabled && tenant.Transport == TenantTransport.Route) + { + if (string.IsNullOrWhiteSpace(tenant.Tenant)) + { + throw new InvalidOperationException("Tenant is enabled for route transport but no tenant value is provided."); + } + + baseAuthority = "/" + tenant.Tenant.Trim('/') + baseAuthority; + } + + return baseAuthority + "/" + relativePath.TrimStart('/'); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/ClientConfigurationMarker.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/ClientConfigurationMarker.cs new file mode 100644 index 00000000..8d2b8f93 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/ClientConfigurationMarker.cs @@ -0,0 +1,15 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Options; + +internal sealed class ClientConfigurationMarker +{ + private bool _configured; + + public void MarkConfigured() + { + if (_configured) + throw new InvalidOperationException("UltimateAuth client options were configured multiple times. " + + "Call AddUltimateAuthClient() OR AddUltimateAuthClientBlazor(), not both with configure delegates."); + + _configured = true; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs new file mode 100644 index 00000000..eebd7e3f --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientAutoRefreshOptions.cs @@ -0,0 +1,26 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Options; + +/// +/// Controls automatic background refresh behavior. +/// This does NOT guarantee session continuity. +/// +public sealed class UAuthClientAutoRefreshOptions +{ + /// + /// Enables background refresh coordination. + /// Default: true for BlazorServer, false otherwise. + /// + public bool Enabled { get; set; } = true; + + /// + /// Interval for background refresh attempts. + /// This is a UX / keep-alive setting, NOT a security policy. + /// + public TimeSpan? Interval { get; set; } + + // TODO: Future enhancement: Add jitter to avoid synchronized refresh storms in multi-tab scenarios. + ///// + ///// Optional jitter to avoid synchronized refresh storms. + ///// + //public TimeSpan? Jitter { get; set; } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs new file mode 100644 index 00000000..0b709c79 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientEndpointOptions.cs @@ -0,0 +1,20 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientEndpointOptions +{ + /// + /// Base URL of UAuthHub (e.g. https://localhost:6110) + /// + public string BasePath { get; set; } = "/auth"; + + public string Login { get; set; } = "/login"; + public string TryLogin { get; set; } = "/try-login"; + public string Logout { get; set; } = "/logout"; + public string Refresh { get; set; } = "/refresh"; + public string Reauth { get; set; } = "/reauth"; + public string Validate { get; set; } = "/validate"; + public string PkceAuthorize { get; set; } = "/pkce/authorize"; + public string PkceTryComplete { get; set; } = "/pkce/try-complete"; + public string PkceComplete { get; set; } = "/pkce/complete"; + public string HubLoginPath { get; set; } = "/uauthhub/entry"; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs new file mode 100644 index 00000000..9cb1d376 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientLoginFlowOptions.cs @@ -0,0 +1,25 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientLoginFlowOptions +{ + /// + /// Default return URL after a successful login flow. + /// If not set, global default return url or current location will be used. + /// + public string? ReturnUrl { get; set; } + + /// + /// Allows posting credentials (e.g. username/password) directly to the server. + /// + /// โš ๏ธ SECURITY WARNING: + /// This MUST NOT be enabled for public clients (e.g. Blazor WASM, SPA). + /// Public clients are required to use PKCE-based login flows. + /// + /// Enable this option ONLY for trusted server-hosted clients + /// such as Blazor Server or UAuthHub. + /// + /// This option may be temporarily enabled for debugging purposes, + /// but doing so is inherently insecure and MUST NOT be used in production. + /// + public bool AllowCredentialPost { get; set; } = false; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs new file mode 100644 index 00000000..c5d9caf6 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientMultiTenantOptions.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientMultiTenantOptions +{ + /// + /// Enables tenant propagation from client to server. + /// + public bool Enabled { get; set; } + + /// + /// Tenant identifier to propagate. + /// Client does NOT resolve tenant, only carries it. + /// + public string? Tenant { get; set; } + + /// + /// Transport mechanism for tenant propagation. + /// + public TenantTransport Transport { get; set; } = TenantTransport.None; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs new file mode 100644 index 00000000..7931121d --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientOptions.cs @@ -0,0 +1,27 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientOptions +{ + public UAuthClientProfile ClientProfile { get; set; } = UAuthClientProfile.NotSpecified; + public bool AutoDetectClientProfile { get; set; } = true; + + /// + /// Global fallback return URL used by interactive authentication flows + /// when no flow-specific return URL is provided. + /// + public string? DefaultReturnUrl { get; set; } + + public UAuthStateEventOptions StateEvents { get; set; } = new(); + public UAuthClientEndpointOptions Endpoints { get; set; } = new(); + public UAuthClientLoginFlowOptions Login { get; set; } = new(); + + /// + /// Options related to PKCE-based login flows. + /// + public UAuthClientPkceLoginFlowOptions Pkce { get; set; } = new(); + public UAuthClientAutoRefreshOptions AutoRefresh { get; set; } = new(); + public UAuthClientReauthOptions Reauth { get; init; } = new(); + public UAuthClientMultiTenantOptions MultiTenant { get; set; } = new(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs new file mode 100644 index 00000000..16fa248c --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientPkceLoginFlowOptions.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientPkceLoginFlowOptions +{ + /// + /// Enables PKCE login support. + /// + public bool Enabled { get; set; } = true; + + public string? ReturnUrl { get; set; } + + /// + /// Called after authorization_code is issued, + /// before redirecting to the Hub. + /// + public Func? OnAuthorized { get; set; } + + /// + /// If false, BeginPkceAsync will NOT redirect automatically. + /// Caller is responsible for navigation. + /// + public bool AutoRedirect { get; set; } = true; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs new file mode 100644 index 00000000..96e06a19 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientProfileDetector.cs @@ -0,0 +1,30 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Client.Options; + +internal sealed class UAuthClientProfileDetector : IClientProfileDetector +{ + public UAuthClientProfile Detect(IServiceProvider sp) + { + if (sp.GetService() != null) + return UAuthClientProfile.UAuthHub; + + if (Type.GetType("Microsoft.Maui.Controls.Application, Microsoft.Maui.Controls", throwOnError: false) is not null) + return UAuthClientProfile.Maui; + + if (AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == "Microsoft.AspNetCore.Components.WebAssembly")) + return UAuthClientProfile.BlazorWasm; + + // Warning: This detection method may not be 100% reliable in all hosting scenarios. + if (AppDomain.CurrentDomain.GetAssemblies().Any(a => a.GetName().Name == "Microsoft.AspNetCore.Components.Server")) + { + return UAuthClientProfile.BlazorServer; + } + + // Default to WebServer profile for other ASP.NET Core scenarios such as MVC, Razor Pages, minimal APIs, etc. + // NotSpecified should only be used when user explicitly sets it. (For example in unit tests) + return UAuthClientProfile.WebServer; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs new file mode 100644 index 00000000..3cb6796f --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthClientReauthOptions.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Options; + +// TODO: Add ClearCookieOnReauth +public sealed class UAuthClientReauthOptions +{ + public ReauthBehavior Behavior { get; set; } = ReauthBehavior.Redirect; + public string? RedirectPath { get; set; } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs new file mode 100644 index 00000000..d725091e --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthOptionsPostConfigure.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Infrastructure; + +internal sealed class UAuthClientOptionsPostConfigure : IPostConfigureOptions +{ + private readonly IClientProfileDetector _detector; + private readonly IServiceProvider _services; + + public UAuthClientOptionsPostConfigure(IClientProfileDetector detector, IServiceProvider services) + { + _detector = detector; + _services = services; + } + + public void PostConfigure(string? name, UAuthClientOptions options) + { + if (!options.AutoDetectClientProfile) + return; + + if (options.ClientProfile != UAuthClientProfile.NotSpecified) + return; + + options.ClientProfile = _detector.Detect(_services); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs new file mode 100644 index 00000000..51bf9632 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/UAuthStateEventOptions.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Options; + +public class UAuthStateEventOptions +{ + public UAuthStateEventHandlingMode HandlingMode { get; set; } = UAuthStateEventHandlingMode.Patch; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs new file mode 100644 index 00000000..d00b4f2e --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientEndpointOptionsValidator.cs @@ -0,0 +1,26 @@ +๏ปฟusing Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientEndpointOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthClientOptions options) + { + var e = options.Endpoints; + + if (string.IsNullOrWhiteSpace(e.BasePath)) + { + return ValidateOptionsResult.Fail("Endpoints.BasePath must be specified."); + } + + if (string.IsNullOrWhiteSpace(e.Login) || + string.IsNullOrWhiteSpace(e.Logout) || + string.IsNullOrWhiteSpace(e.Refresh) || + string.IsNullOrWhiteSpace(e.Validate)) + { + return ValidateOptionsResult.Fail("One or more required endpoint paths are missing in UAuthClientEndpointOptions."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs new file mode 100644 index 00000000..98e2ec3c --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Options/Validators/UAuthClientOptionsValidator.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Options; + +public sealed class UAuthClientOptionsValidator : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, UAuthClientOptions options) + { + if (options.ClientProfile == UAuthClientProfile.NotSpecified && options.AutoDetectClientProfile == false) + { + return ValidateOptionsResult.Fail("ClientProfile is NotSpecified while AutoDetectClientProfile is disabled. " + + "Either specify a ClientProfile or enable auto-detection."); + } + + return ValidateOptionsResult.Success; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/README.md b/src/client/CodeBeam.UltimateAuth.Client/README.md new file mode 100644 index 00000000..1fe71163 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Client + +Core client engine for UltimateAuth. + +This package provides platform-agnostic authentication functionality including: + +- Login / logout flows +- Token refresh handling +- PKCE support +- Session and state management +- Client-side domain services + +--- + +## โš ๏ธ Important + +This package does **NOT** include any platform-specific implementations such as: + +- HTTP / JS transport +- Browser storage +- UI integration + +To use this package in an application and for complete experience, you must install a platform adapter: + +- CodeBeam.UltimateAuth.Client.Blazor + +๐Ÿ“ฆ Included automatically + +You typically do NOT need to install this package directly. + +It is included transitively by platform packages like: CodeBeam.UltimateAuth.Client.Blazor \ No newline at end of file diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs new file mode 100644 index 00000000..d240224a --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/IUAuthClientProductInfoProvider.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Client.Runtime; + +public interface IUAuthClientProductInfoProvider +{ + UAuthClientProductInfo Get(); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs new file mode 100644 index 00000000..771c92c5 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfo.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; + +namespace CodeBeam.UltimateAuth.Client.Runtime; + +public sealed class UAuthClientProductInfo +{ + public string ProductName { get; init; } = "UltimateAuth Client"; + public string Version { get; init; } = default!; + public string? InformationalVersion { get; init; } + + public UAuthClientProfile ClientProfile { get; init; } = default!; + + public DateTimeOffset StartedAt { get; init; } + public string RuntimeId { get; init; } = Guid.NewGuid().ToString("n"); + + public bool AutoRefreshEnabled { get; init; } + public TimeSpan? RefreshInterval { get; init; } + public ReauthBehavior ReauthBehavior { get; init; } + + public string FrameworkDescription { get; init; } = default!; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs new file mode 100644 index 00000000..d939fda0 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Runtime/UAuthClientProductInfoProvider.cs @@ -0,0 +1,32 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Options; +using Microsoft.Extensions.Options; +using System.Reflection; + +namespace CodeBeam.UltimateAuth.Client.Runtime; + +internal sealed class UAuthClientProductInfoProvider : IUAuthClientProductInfoProvider +{ + private readonly UAuthClientProductInfo _info; + + public UAuthClientProductInfoProvider(IOptions options) + { + var asm = typeof(UAuthClientProductInfoProvider).Assembly; + var opts = options.Value; + + _info = new UAuthClientProductInfo + { + Version = asm.GetName().Version?.ToString(3) ?? "unknown", + InformationalVersion = asm.GetCustomAttribute()?.InformationalVersion, + StartedAt = DateTimeOffset.UtcNow, + ClientProfile = opts.ClientProfile, + + AutoRefreshEnabled = opts.AutoRefresh.Enabled, + RefreshInterval = opts.AutoRefresh.Interval, + ReauthBehavior = opts.Reauth.Behavior, + + FrameworkDescription = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription + }; + } + + public UAuthClientProductInfo Get() => _info; +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs new file mode 100644 index 00000000..78b6ffd9 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IAuthorizationClient.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IAuthorizationClient +{ + Task> CheckAsync(AuthorizationCheckRequest request); + Task> GetMyRolesAsync(PageRequest? request = null); + Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null); + Task AssignRoleToUserAsync(AssignRoleRequest request); + Task RemoveRoleFromUserAsync(RemoveRoleRequest request); + + Task> CreateRoleAsync(CreateRoleRequest request); + Task>> QueryRolesAsync(RoleQuery request); + Task RenameRoleAsync(RenameRoleRequest request); + Task SetRolePermissionsAsync(SetRolePermissionsRequest request); + Task> DeleteRoleAsync(DeleteRoleRequest request); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs new file mode 100644 index 00000000..b9241513 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ICredentialClient.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface ICredentialClient +{ + Task> AddMyAsync(AddCredentialRequest request); + Task> ChangeMyAsync(ChangeCredentialRequest request); + Task RevokeMyAsync(RevokeCredentialRequest request); + Task> BeginResetMyAsync(BeginResetCredentialRequest request); + Task> CompleteResetMyAsync(CompleteResetCredentialRequest request); + + Task> AddUserAsync(UserKey userKey, AddCredentialRequest request); + Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request); + Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request); + Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request); + Task> CompleteResetUserAsync(UserKey userKey, CompleteResetCredentialRequest request); + Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs new file mode 100644 index 00000000..f2cda9e5 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IFlowClient.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +// TODO: Add ReauthAsync +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IFlowClient +{ + Task LoginAsync(LoginRequest request, string? returnUrl = null); + Task TryLoginAsync(LoginRequest request, UAuthSubmitMode mode, string? returnUrl = null); + + Task LogoutAsync(); + Task RefreshAsync(bool isAuto = false); + //Task ReauthAsync(); + Task ValidateAsync(); + + Task BeginPkceAsync(string? returnUrl = null); + Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode); + Task CompletePkceLoginAsync(PkceCompleteRequest request); + + Task> LogoutMyDeviceAsync(LogoutDeviceRequest request); + Task LogoutMyOtherDevicesAsync(); + Task LogoutAllMyDevicesAsync(); + Task> LogoutUserDeviceAsync(UserKey userKey, LogoutDeviceRequest request); + Task LogoutUserOtherDevicesAsync(UserKey userKey, LogoutOtherDevicesRequest request); + Task LogoutAllUserDevicesAsync(UserKey userKey); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs new file mode 100644 index 00000000..1ecb6eb4 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/ISessionClient.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface ISessionClient +{ + Task>> GetMyChainsAsync(PageRequest? request = null); + Task> GetMyChainDetailAsync(SessionChainId chainId); + Task> RevokeMyChainAsync(SessionChainId chainId); + Task RevokeMyOtherChainsAsync(); + Task RevokeAllMyChainsAsync(); + + + Task>> GetUserChainsAsync(UserKey userKey, PageRequest? request = null); + Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId); + Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId); + Task> RevokeUserChainAsync(UserKey userKey, SessionChainId chainId); + Task RevokeUserRootAsync(UserKey userKey); + Task RevokeAllUserChainsAsync(UserKey userKey); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs new file mode 100644 index 00000000..54dcf6bd --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUAuthClient.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Services; + +namespace CodeBeam.UltimateAuth.Client; + +public interface IUAuthClient +{ + IFlowClient Flows { get; } + ISessionClient Sessions { get; } + IUserClient Users { get; } + IUserIdentifierClient Identifiers { get; } + ICredentialClient Credentials { get; } + IAuthorizationClient Authorization { get; } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs new file mode 100644 index 00000000..3034f685 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserClient.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IUserClient +{ + Task>> QueryAsync(UserQuery query); + Task> CreateAsync(CreateUserRequest request); + Task> CreateAsAdminAsync(CreateUserRequest request); + Task> ChangeMyStatusAsync(ChangeUserStatusSelfRequest request); + Task> ChangeUserStatusAsync(UserKey userKey, ChangeUserStatusAdminRequest request); + Task DeleteMeAsync(); + Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request); + + Task> GetMeAsync(); + Task UpdateMeAsync(UpdateProfileRequest request); + + Task> GetUserAsync(UserKey userKey); + Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs new file mode 100644 index 00000000..6ce76868 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/Abstractions/IUserIdentifierClient.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Client.Services; + +public interface IUserIdentifierClient +{ + Task>> GetMyAsync(PageRequest? request = null); + Task AddMyAsync(AddUserIdentifierRequest request); + Task UpdateMyAsync(UpdateUserIdentifierRequest request); + Task SetMyPrimaryAsync(SetPrimaryUserIdentifierRequest request); + Task UnsetMyPrimaryAsync(UnsetPrimaryUserIdentifierRequest request); + Task VerifyMyAsync(VerifyUserIdentifierRequest request); + Task DeleteMyAsync(DeleteUserIdentifierRequest request); + + Task>> GetUserAsync(UserKey userKey, PageRequest? request = null); + Task AddUserAsync(UserKey userKey, AddUserIdentifierRequest request); + Task UpdateUserAsync(UserKey userKey, UpdateUserIdentifierRequest request); + Task SetUserPrimaryAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request); + Task UnsetUserPrimaryAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request); + Task VerifyUserAsync(UserKey userKey, VerifyUserIdentifierRequest request); + Task DeleteUserAsync(UserKey userKey, DeleteUserIdentifierRequest request); +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs new file mode 100644 index 00000000..822af1ae --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthAuthorizationClient.cs @@ -0,0 +1,124 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal sealed class UAuthAuthorizationClient : IAuthorizationClient +{ + private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; + private readonly UAuthClientOptions _options; + + public UAuthAuthorizationClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) + { + _request = request; + _events = events; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task> CheckAsync(AuthorizationCheckRequest request) + { + var raw = await _request.SendJsonAsync(Url("/authorization/check"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetMyRolesAsync(PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/me/authorization/roles/get"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetUserRolesAsync(UserKey userKey, PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{userKey.Value}/roles/get"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task AssignRoleToUserAsync(AssignRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/assign"), request.RoleName); + + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task RemoveRoleFromUserAsync(RemoveRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/users/{request.UserKey.Value}/roles/remove"), request.RoleName); + + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task> CreateRoleAsync(CreateRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url("/admin/authorization/roles/create"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task>> QueryRolesAsync(RoleQuery request) + { + var raw = await _request.SendJsonAsync(Url("/admin/authorization/roles/query"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task RenameRoleAsync(RenameRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.Id.Value}/rename"), request); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task SetRolePermissionsAsync(SetRolePermissionsRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.RoleId.Value}/permissions"), request); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task> DeleteRoleAsync(DeleteRoleRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/authorization/roles/{request.Id.Value}/delete"), request); + var result = UAuthResultMapper.FromJson(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.AuthorizationChanged, _options.StateEvents.HandlingMode)); + } + + return result; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs new file mode 100644 index 00000000..6df7e74d --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthClient.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Services; + +namespace CodeBeam.UltimateAuth.Client; + +internal sealed class UAuthClient : IUAuthClient +{ + public IFlowClient Flows { get; } + public ISessionClient Sessions { get; } + public IUserClient Users { get; } + public IUserIdentifierClient Identifiers { get; } + public ICredentialClient Credentials { get; } + public IAuthorizationClient Authorization { get; } + + public UAuthClient(IFlowClient flows, ISessionClient session, IUserClient users, IUserIdentifierClient identifiers, ICredentialClient credentials, IAuthorizationClient authorization) + { + Flows = flows; + Sessions = session; + Users = users; + Identifiers = identifiers; + Credentials = credentials; + Authorization = authorization; + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs new file mode 100644 index 00000000..1d02db68 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthCredentialClient.cs @@ -0,0 +1,104 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal sealed class UAuthCredentialClient : ICredentialClient +{ + private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; + private readonly UAuthClientOptions _options; + + public UAuthCredentialClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) + { + _request = request; + _events = events; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task> AddMyAsync(AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeMyAsync(ChangeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/credentials/change"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChangedSelf, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeMyAsync(RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/me/credentials/revoke"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task> BeginResetMyAsync(BeginResetCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/begin"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> CompleteResetMyAsync(CompleteResetCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/me/credentials/reset/complete"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.CredentialsChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.FromJson(raw); + } + + + public async Task> AddUserAsync(UserKey userKey, AddCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/add"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeUserAsync(UserKey userKey, ChangeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/change"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserAsync(UserKey userKey, RevokeCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/revoke"), request); + return UAuthResultMapper.From(raw); + } + + public async Task> BeginResetUserAsync(UserKey userKey, BeginResetCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/reset/begin"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> CompleteResetUserAsync(UserKey userKey, CompleteResetCredentialRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/credentials/reset/complete"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task DeleteUserAsync(UserKey userKey, DeleteCredentialRequest request) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/credentials/delete")); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs new file mode 100644 index 00000000..988c0ab2 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthFlowClient.cs @@ -0,0 +1,459 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Extensions; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; +using System.Net; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal class UAuthFlowClient : IFlowClient +{ + private readonly IUAuthRequestClient _post; + private readonly IUAuthClientEvents _events; + private readonly IClientDeviceProvider _clientDeviceProvider; + private readonly IReturnUrlProvider _returnUrlProvider; + private readonly UAuthClientOptions _options; + private readonly UAuthClientDiagnostics _diagnostics; + + public UAuthFlowClient(IUAuthRequestClient post, IUAuthClientEvents events, IClientDeviceProvider clientDeviceProvider, IReturnUrlProvider returnUrlProvider, IOptions options, UAuthClientDiagnostics diagnostics) + { + _post = post; + _events = events; + _clientDeviceProvider = clientDeviceProvider; + _returnUrlProvider = returnUrlProvider; + _options = options.Value; + _diagnostics = diagnostics; + } + + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task LoginAsync(LoginRequest request, string? returnUrl = null) + { + EnsureCanPost(); + + var payload = BuildPayload(request, returnUrl); + + var url = Url(_options.Endpoints.Login); + await _post.NavigateAsync(url, payload); + } + + public async Task TryLoginAsync(LoginRequest request, UAuthSubmitMode mode, string? returnUrl = null) + { + EnsureCanPost(); + + var payload = BuildPayload(request, returnUrl); + + var tryUrl = Url(_options.Endpoints.TryLogin); + var commitUrl = Url(_options.Endpoints.Login); + + switch (mode) + { + case UAuthSubmitMode.TryOnly: + { + var result = await _post.SendJsonAsync(tryUrl, payload); + + if (result.Body is null) + throw new UAuthProtocolException("Empty response body."); + + TryLoginResult parsed; + + try + { + parsed = result.Body.Value.Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + } + catch (JsonException ex) + { + throw new UAuthProtocolException("Invalid try-login result.", ex); + } + + if (parsed is null) + throw new UAuthProtocolException("Invalid try-login result."); + + return parsed; + } + + case UAuthSubmitMode.DirectCommit: + { + await _post.NavigateAsync(commitUrl, payload); + return new TryLoginResult { Success = true }; + } + + case UAuthSubmitMode.TryAndCommit: + default: + { + var result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); + + if (result is null) + throw new UAuthProtocolException("Invalid try-login result."); + + return result; + } + } + } + + public async Task LogoutAsync() + { + var url = Url(_options.Endpoints.Logout); + await _post.NavigateAsync(url); + } + + public async Task RefreshAsync(bool isAuto = false) + { + if (isAuto == false) + { + _diagnostics.MarkManualRefresh(); + } + + var url = Url(_options.Endpoints.Refresh); + var result = await _post.SendFormAsync(url); + + if (result.Status == 401) + { + _diagnostics.MarkRefreshReauthRequired(); + return new RefreshResult + { + IsSuccess = false, + Status = result.Status, + Outcome = RefreshOutcome.ReauthRequired + }; + } + + var refreshOutcome = RefreshOutcomeParser.Parse(result.RefreshOutcome); + switch (refreshOutcome) + { + case RefreshOutcome.NoOp: + _diagnostics.MarkRefreshNoOp(); + break; + case RefreshOutcome.Touched: + _diagnostics.MarkRefreshTouched(); + break; + case RefreshOutcome.Rotated: + _diagnostics.MarkRefreshRotated(); + break; + case RefreshOutcome.ReauthRequired: + _diagnostics.MarkRefreshReauthRequired(); + break; + case RefreshOutcome.Success: + _diagnostics.MarkRefreshSuccess(); + break; + } + + return new RefreshResult + { + IsSuccess = result.Ok, + Status = result.Status, + Outcome = refreshOutcome + }; + } + + //public async Task ReauthAsync() + //{ + // var url = Url(_options.Endpoints.Reauth); + // await _post.NavigateAsync(url); + //} + + public async Task ValidateAsync() + { + var url = Url(_options.Endpoints.Validate); + var raw = await _post.SendFormAsync(url); + + if (raw.Status == 0) + throw new UAuthTransportException("Network error during validation."); + + if (raw.Status >= 500) + throw new UAuthTransportException("Server error during validation.", (HttpStatusCode)raw.Status); + + if (raw.Body is null) + throw new UAuthProtocolException("Validation response body was empty."); + + AuthValidationResult? body; + + try + { + body = raw.Body.Value.Deserialize( + new JsonSerializerOptions{ PropertyNameCaseInsensitive = true }); + } + catch (Exception ex) + { + throw new UAuthProtocolException("Invalid validation response format.", ex); + } + + if (body is null) + throw new UAuthProtocolException("Malformed validation response."); + + if (raw.Status == 401 || (raw.Status >= 200 && raw.Status < 300)) + { + // Don't set refresh mode to validate here, it's already validated. + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.ValidationCalled, UAuthStateEventHandlingMode.Patch)); + return body; + } + + if (raw.Status >= 400 && raw.Status < 500) + throw new UAuthProtocolException($"Unexpected client error during validation: {raw.Status}"); + + throw new UAuthTransportException($"Unexpected status code: {raw.Status}", (HttpStatusCode)raw.Status); + } + + public async Task BeginPkceAsync(string? returnUrl = null) + { + var pkce = _options.Pkce; + var device = await _clientDeviceProvider.GetAsync(); + + if (!pkce.Enabled) + throw new InvalidOperationException("PKCE login is disabled by configuration."); + + var verifier = CreateVerifier(); + var challenge = CreateChallenge(verifier); + + var authorizeUrl = Url(_options.Endpoints.PkceAuthorize); + + var raw = await _post.SendFormAsync( + authorizeUrl, + new Dictionary + { + ["code_challenge"] = challenge, + ["challenge_method"] = "S256" + }); + + if (!raw.Ok || raw.Body is null) + throw new InvalidOperationException("PKCE authorize failed."); + + var response = raw.Body.Value.Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (response is null || string.IsNullOrWhiteSpace(response.AuthorizationCode)) + throw new InvalidOperationException("Invalid PKCE authorize response."); + + if (pkce.OnAuthorized is not null) + await pkce.OnAuthorized(response); + + var resolvedReturnUrl = returnUrl + ?? pkce.ReturnUrl + ?? _options.Login.ReturnUrl + ?? _options.DefaultReturnUrl + ?? _returnUrlProvider.GetCurrentUrl(); + + if (pkce.AutoRedirect) + { + await NavigateToHubLoginAsync(response.AuthorizationCode, verifier, resolvedReturnUrl, device); + } + } + + public async Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode) + { + if (mode == UAuthSubmitMode.DirectCommit) + { + await CompletePkceLoginAsync(request); + return new TryPkceLoginResult { Success = true }; + } + + if (request is null) + throw new ArgumentNullException(nameof(request)); + + if (!_options.Pkce.Enabled) + throw new InvalidOperationException("PKCE login is disabled."); + + var tryUrl = Url(_options.Endpoints.PkceTryComplete); + var commitUrl = Url(_options.Endpoints.PkceComplete); + + var payload = new Dictionary + { + ["authorization_code"] = request.AuthorizationCode, + ["code_verifier"] = request.CodeVerifier, + ["Identifier"] = request.Identifier ?? string.Empty, + ["Secret"] = request.Secret ?? string.Empty + }; + + if (!string.IsNullOrWhiteSpace(request.ReturnUrl)) + { + payload["return_url"] = request.ReturnUrl; + } + + switch (mode) + { + case UAuthSubmitMode.TryOnly: + { + var raw = await _post.SendJsonAsync(tryUrl, request); + + if (raw.Body is null) + throw new UAuthProtocolException("Empty response body."); + + var parsed = raw.Body.Value.Deserialize( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + + if (parsed is null) + throw new UAuthProtocolException("Invalid PKCE try result."); + + return parsed; + } + + case UAuthSubmitMode.TryAndCommit: + default: + { + var result = await _post.TryAndCommitAsync(tryUrl, commitUrl, payload); + + if (result is null) + throw new UAuthProtocolException("Invalid PKCE try result."); + + return result; + } + } + } + + public async Task CompletePkceLoginAsync(PkceCompleteRequest request) + { + if (request is null) + throw new ArgumentNullException(nameof(request)); + + if (!_options.Pkce.Enabled) + { + throw new InvalidOperationException("PKCE login is disabled by configuration, but a PKCE completion was attempted. " + + "This usually indicates a misconfiguration or an unexpected redirect flow."); + } + + var url = Url(_options.Endpoints.PkceComplete); + + var payload = new Dictionary + { + ["authorization_code"] = request.AuthorizationCode, + ["code_verifier"] = request.CodeVerifier, + ["return_url"] = request.ReturnUrl, + + ["Identifier"] = request.Identifier ?? string.Empty, + ["Secret"] = request.Secret ?? string.Empty, + + ["hub_session_id"] = request.HubSessionId ?? string.Empty, + }; + + await _post.NavigateAsync(url, payload); + } + + public async Task> LogoutMyDeviceAsync(LogoutDeviceRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/me/logout-device"), request); + + if (raw.Ok) + { + var result = UAuthResultMapper.FromJson(raw); + + if (result.Value?.CurrentChain == true) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + } + + return result; + } + + return UAuthResultMapper.FromJson(raw); + } + + public async Task> LogoutUserDeviceAsync(UserKey userKey, LogoutDeviceRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-device"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task LogoutMyOtherDevicesAsync() + { + var raw = await _post.SendJsonAsync(Url("/me/logout-others")); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutUserOtherDevicesAsync(UserKey userKey, LogoutOtherDevicesRequest request) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-others"), request); + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllMyDevicesAsync() + { + var raw = await _post.SendJsonAsync(Url("/me/logout-all")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.LogoutVariant, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task LogoutAllUserDevicesAsync(UserKey userKey) + { + var raw = await _post.SendJsonAsync(Url($"/admin/users/{userKey.Value}/logout-all")); + return UAuthResultMapper.From(raw); + } + + + private void EnsureCanPost() + { + var canPost = ClientLoginCapabilities.CanPostCredentials(_options.ClientProfile); + + if (!_options.Login.AllowCredentialPost && !canPost) + { + throw new InvalidOperationException("Direct credential posting is disabled for this client profile. " + + "Public clients (e.g. Blazor WASM) MUST use PKCE-based login flows. " + + "If this is a trusted server-hosted client, you may explicitly enable " + + "Login.AllowCredentialPost, but doing so is insecure for public clients."); + } + } + + private IDictionary BuildPayload(LoginRequest request, string? returnUrl) + { + var payload = request.ToDictionary(); + + var resolvedReturnUrl = + returnUrl + ?? _options.Login.ReturnUrl + ?? _options.DefaultReturnUrl; + + if (!string.IsNullOrWhiteSpace(resolvedReturnUrl)) + { + payload["return_url"] = resolvedReturnUrl; + } + + return payload; + } + + private Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string returnUrl, DeviceContext device) + { + var hubLoginUrl = Url(_options.Endpoints.HubLoginPath); + + var deviceJson = JsonSerializer.Serialize(device); + var deviceEncoded = Base64Url.Encode(Encoding.UTF8.GetBytes(deviceJson)); + + var data = new Dictionary + { + ["authorization_code"] = authorizationCode, + ["code_verifier"] = codeVerifier, + ["return_url"] = returnUrl, + ["client_profile"] = _options.ClientProfile.ToString(), + ["device"] = deviceEncoded + }; + + return _post.NavigateAsync(hubLoginUrl, data); + } + + private static string CreateVerifier() + { + var bytes = RandomNumberGenerator.GetBytes(32); + return Base64Url.Encode(bytes); + } + + private static string CreateChallenge(string verifier) + { + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); + return Base64Url.Encode(hash); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs new file mode 100644 index 00000000..80e5ce01 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthSessionClient.cs @@ -0,0 +1,109 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal sealed class UAuthSessionClient : ISessionClient +{ + private readonly IUAuthRequestClient _request; + private readonly UAuthClientOptions _options; + private readonly IUAuthClientEvents _events; + + public UAuthSessionClient(IUAuthRequestClient request, IOptions options, IUAuthClientEvents events) + { + _request = request; + _options = options.Value; + _events = events; + } + + private string Url(string path) + => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + + public async Task>> GetMyChainsAsync(PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/me/sessions/chains"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetMyChainDetailAsync(SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/me/sessions/chains/{chainId.Value}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> RevokeMyChainAsync(SessionChainId chainId) + { + var raw = await _request.SendJsonAsync(Url($"/me/sessions/chains/{chainId.Value}/revoke")); + var result = UAuthResultMapper.FromJson(raw); + + if (result.Value?.CurrentChain == true) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode)); + } + + return result; + } + + public async Task RevokeMyOtherChainsAsync() + { + var raw = await _request.SendFormAsync(Url("/me/sessions/revoke-others")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllMyChainsAsync() + { + var raw = await _request.SendFormAsync(Url("/me/sessions/revoke-all")); + var result = UAuthResultMapper.From(raw); + + if (result.IsSuccess) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.SessionRevoked, _options.StateEvents.HandlingMode)); + } + + return result; + } + + + public async Task>> GetUserChainsAsync(UserKey userKey, PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/sessions/chains"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> GetUserChainDetailAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/sessions/chains/{chainId.Value}")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserSessionAsync(UserKey userKey, AuthSessionId sessionId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/sessions/{sessionId.Value}/revoke")); + return UAuthResultMapper.From(raw); + } + + public async Task> RevokeUserChainAsync(UserKey userKey, SessionChainId chainId) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/sessions/chains/{chainId.Value}/revoke")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task RevokeUserRootAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/sessions/revoke-root")); + return UAuthResultMapper.From(raw); + } + + public async Task RevokeAllUserChainsAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/sessions/revoke-all")); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs new file mode 100644 index 00000000..001afe2e --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserClient.cs @@ -0,0 +1,104 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal sealed class UAuthUserClient : IUserClient +{ + private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; + private readonly UAuthClientOptions _options; + + public UAuthUserClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) + { + _request = request; + _events = events; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task> GetMeAsync() + { + var raw = await _request.SendFormAsync(Url("/me/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task UpdateMeAsync(UpdateProfileRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/update"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); + } + return UAuthResultMapper.From(raw); + } + + public async Task DeleteMeAsync() + { + var raw = await _request.SendJsonAsync(Url("/me/delete")); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.UserDeleted, UAuthStateEventHandlingMode.Patch)); + } + return UAuthResultMapper.From(raw); + } + + public async Task>> QueryAsync(UserQuery query) + { + query ??= new UserQuery(); + var raw = await _request.SendJsonAsync(Url("/admin/users/query"), query); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task> CreateAsync(CreateUserRequest request) + { + var raw = await _request.SendJsonAsync(Url("/users/create"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> CreateAsAdminAsync(CreateUserRequest request) + { + var raw = await _request.SendJsonAsync(Url("/admin/users/create"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeMyStatusAsync(ChangeUserStatusSelfRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/status"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.ProfileChanged, _options.StateEvents.HandlingMode, request)); + } + return UAuthResultMapper.FromJson(raw); + } + + public async Task> ChangeUserStatusAsync(UserKey userKey, ChangeUserStatusAdminRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/status"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> DeleteUserAsync(UserKey userKey, DeleteUserRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/delete"), request); + return UAuthResultMapper.FromJson(raw); + } + + public async Task> GetUserAsync(UserKey userKey) + { + var raw = await _request.SendFormAsync(Url($"/admin/users/{userKey.Value}/profile/get")); + return UAuthResultMapper.FromJson(raw); + } + + public async Task UpdateUserAsync(UserKey userKey, UpdateProfileRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/profile/update"), request); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs new file mode 100644 index 00000000..fa240378 --- /dev/null +++ b/src/client/CodeBeam.UltimateAuth.Client/Services/UAuthUserIdentifierClient.cs @@ -0,0 +1,135 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Client.Services; + +internal class UAuthUserIdentifierClient : IUserIdentifierClient +{ + private readonly IUAuthRequestClient _request; + private readonly IUAuthClientEvents _events; + private readonly UAuthClientOptions _options; + + public UAuthUserIdentifierClient(IUAuthRequestClient request, IUAuthClientEvents events, IOptions options) + { + _request = request; + _events = events; + _options = options.Value; + } + + private string Url(string path) => UAuthUrlBuilder.Build(_options.Endpoints.BasePath, path, _options.MultiTenant); + + public async Task>> GetMyAsync(PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url("/me/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddMyAsync(AddUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/identifiers/add"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); + } + return UAuthResultMapper.From(raw); + } + + public async Task UpdateMyAsync(UpdateUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/identifiers/update"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgs(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode, request)); + } + return UAuthResultMapper.From(raw); + } + + public async Task SetMyPrimaryAsync(SetPrimaryUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/identifiers/set-primary"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task UnsetMyPrimaryAsync(UnsetPrimaryUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/identifiers/unset-primary"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task VerifyMyAsync(VerifyUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/identifiers/verify"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task DeleteMyAsync(DeleteUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url("/me/identifiers/delete"), request); + if (raw.Ok) + { + await _events.PublishAsync(new UAuthStateEventArgsEmpty(UAuthStateEvent.IdentifiersChanged, _options.StateEvents.HandlingMode)); + } + return UAuthResultMapper.From(raw); + } + + public async Task>> GetUserAsync(UserKey userKey, PageRequest? request = null) + { + request ??= new PageRequest(); + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/get"), request); + return UAuthResultMapper.FromJson>(raw); + } + + public async Task AddUserAsync(UserKey userKey, AddUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/add"), request); + return UAuthResultMapper.From(raw); + } + + public async Task UpdateUserAsync(UserKey userKey, UpdateUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/update"), request); + return UAuthResultMapper.From(raw); + } + + public async Task SetUserPrimaryAsync(UserKey userKey, SetPrimaryUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/set-primary"), request); + return UAuthResultMapper.From(raw); + } + + public async Task UnsetUserPrimaryAsync(UserKey userKey, UnsetPrimaryUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/unset-primary"), request); + return UAuthResultMapper.From(raw); + } + + public async Task VerifyUserAsync(UserKey userKey, VerifyUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/verify"), request); + return UAuthResultMapper.From(raw); + } + + public async Task DeleteUserAsync(UserKey userKey, DeleteUserIdentifierRequest request) + { + var raw = await _request.SendJsonAsync(Url($"/admin/users/{userKey.Value}/identifiers/delete"), request); + return UAuthResultMapper.From(raw); + } +} diff --git a/src/client/CodeBeam.UltimateAuth.Client/logo.png b/src/client/CodeBeam.UltimateAuth.Client/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/client/CodeBeam.UltimateAuth.Client/logo.png differ diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj new file mode 100644 index 00000000..0885e14c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/CodeBeam.UltimateAuth.Credentials.Contracts.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Credentials.Contracts + + + Shared contracts and cross-boundary types for UltimateAuth Credentials module. + Includes credential identifiers, DTOs and shared models used between client and server. + Does NOT include domain logic or persistence. + + + authentication;credentials;identity;contracts;shared;dto;auth-framework + logo.png + README.md + + + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs new file mode 100644 index 00000000..aa6a924e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Domain/CredentialKey.cs @@ -0,0 +1,7 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public readonly record struct CredentialKey( + TenantKey Tenant, + Guid Id); diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs new file mode 100644 index 00000000..6141c3d0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialInfo.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialInfo +{ + public Guid Id { get; set; } + public CredentialType Type { get; init; } + + public CredentialSecurityStatus Status { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset? LastUsedAt { get; init; } + + public DateTimeOffset? ExpiresAt { get; init; } + + public DateTimeOffset? RevokedAt { get; init; } + + public string? Source { get; init; } + + public long Version { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs new file mode 100644 index 00000000..e4e10c5c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialMetadata.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialMetadata +{ + public DateTimeOffset? LastUsedAt { get; init; } + public string? Source { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs new file mode 100644 index 00000000..04edc296 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityState.cs @@ -0,0 +1,90 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Errors; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed class CredentialSecurityState +{ + public DateTimeOffset? RevokedAt { get; } + public DateTimeOffset? ExpiresAt { get; } + public Guid SecurityStamp { get; } + + public bool IsRevoked => RevokedAt != null; + public bool IsExpired(DateTimeOffset now) => ExpiresAt != null && ExpiresAt <= now; + + public CredentialSecurityState( + DateTimeOffset? revokedAt = null, + DateTimeOffset? expiresAt = null, + Guid securityStamp = default) + { + RevokedAt = revokedAt; + ExpiresAt = expiresAt; + SecurityStamp = securityStamp; + } + + public CredentialSecurityStatus Status(DateTimeOffset now) + { + if (RevokedAt is not null) + return CredentialSecurityStatus.Revoked; + + if (IsExpired(now)) + return CredentialSecurityStatus.Expired; + + return CredentialSecurityStatus.Active; + } + + /// + /// Determines whether the credential can be used at the given time. + /// + public bool IsUsable(DateTimeOffset now) => Status(now) == CredentialSecurityStatus.Active; + + public static CredentialSecurityState Active(Guid? securityStamp = null) + { + return new CredentialSecurityState( + revokedAt: null, + expiresAt: null, + securityStamp: securityStamp ?? Guid.NewGuid() + ); + } + + /// + /// Revokes the credential permanently. + /// + public CredentialSecurityState Revoke(DateTimeOffset now) + { + if (RevokedAt is not null) + return this; + + return new CredentialSecurityState( + revokedAt: now, + expiresAt: ExpiresAt, + securityStamp: Guid.NewGuid() + ); + } + + /// + /// Sets or clears expiry while preserving the rest of the state. + /// + public CredentialSecurityState SetExpiry(DateTimeOffset? expiresAt) + { + // optional: normalize already-expired value? keep as-is; domain policy can decide. + if (ExpiresAt == expiresAt) + return this; + + return new CredentialSecurityState( + revokedAt: RevokedAt, + expiresAt: expiresAt, + securityStamp: EnsureStamp(SecurityStamp) + ); + } + + private static Guid EnsureStamp(Guid stamp) => stamp == Guid.Empty ? Guid.NewGuid() : stamp; + + public CredentialSecurityState RotateStamp() + { + return new CredentialSecurityState( + revokedAt: RevokedAt, + expiresAt: ExpiresAt, + securityStamp: Guid.NewGuid() + ); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs new file mode 100644 index 00000000..9f122c09 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Dtos/CredentialSecurityStatus.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public enum CredentialSecurityStatus +{ + Active = 0, + + Revoked = 10, + Locked = 20, + Expired = 30, + ResetRequested = 40, +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs new file mode 100644 index 00000000..d75b3543 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Extensions/CredentialTypeParser.cs @@ -0,0 +1,36 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public static class CredentialTypeParser +{ + private static readonly Dictionary _map = + new(StringComparer.OrdinalIgnoreCase) + { + ["password"] = CredentialType.Password, + + ["otp"] = CredentialType.OneTimeCode, + ["one-time-code"] = CredentialType.OneTimeCode, + + ["email-otp"] = CredentialType.EmailOtp, + ["sms-otp"] = CredentialType.SmsOtp, + + ["totp"] = CredentialType.Totp, + + ["passkey"] = CredentialType.Passkey, + + ["certificate"] = CredentialType.Certificate, + ["cert"] = CredentialType.Certificate, + + ["api-key"] = CredentialType.ApiKey, + ["apikey"] = CredentialType.ApiKey, + + ["external"] = CredentialType.External + }; + + public static bool TryParse(string value, out CredentialType type) => _map.TryGetValue(value, out type); + + public static CredentialType ParseOrThrow(string value) => TryParse(value, out var type) + ? type + : throw new InvalidOperationException($"Unsupported credential type: '{value}'"); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/README.md b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/README.md new file mode 100644 index 00000000..a1c1bd37 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/README.md @@ -0,0 +1,32 @@ +๏ปฟ# UltimateAuth Credentials Contracts + +Shared contracts and cross-boundary models for the Credentials module. + +## Purpose + +This package contains: + +- Credential identifiers +- DTOs +- Shared models used between client and server + +## Does NOT include + +- Domain logic +- Persistence +- Security implementations + +## Usage + +Used by: + +- Server implementations +- Client SDKs +- Custom credential providers + +โš ๏ธ Usually installed transitively via: + +- CodeBeam.UltimateAuth.Server +- CodeBeam.UltimateAuth.Client + +No need to install it directly in most scenarios. \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs new file mode 100644 index 00000000..fcb84bed --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/AddCredentialRequest.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record AddCredentialRequest() +{ + public CredentialType Type { get; init; } + public required string Secret { get; init; } + public string? Source { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs new file mode 100644 index 00000000..437ce3e5 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/BeginResetCredentialRequest.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record BeginResetCredentialRequest +{ + public required string Identifier { get; init; } + public CredentialType CredentialType { get; init; } + public ResetCodeType ResetCodeType { get; init; } + public string? Channel { get; init; } + public TimeSpan? Validity { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs new file mode 100644 index 00000000..56928ebc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ChangeCredentialRequest.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ChangeCredentialRequest +{ + public Guid Id { get; init; } + public string? CurrentSecret { get; init; } + public required string NewSecret { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs new file mode 100644 index 00000000..bd49cb6d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/CompleteResetCredentialRequest.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CompleteResetCredentialRequest +{ + public string? Identifier { get; init; } + public CredentialType CredentialType { get; init; } + public string? ResetToken { get; init; } + public required string NewSecret { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs new file mode 100644 index 00000000..1f808da5 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/DeleteCredentialRequest.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record DeleteCredentialRequest +{ + public Guid Id { get; init; } + public DeleteMode Mode { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs new file mode 100644 index 00000000..710efa1a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/ResetPasswordRequest.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ResetPasswordRequest +{ + public Guid Id { get; init; } + public required string NewPassword { get; init; } + + /// + /// Optional reset token or verification code. + /// + public string? Token { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs new file mode 100644 index 00000000..b6a64cd6 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Request/RevokeCredentialRequest.cs @@ -0,0 +1,17 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record RevokeCredentialRequest +{ + public Guid Id { get; init; } + + /// + /// If specified, credential is revoked until this time. + /// Null means permanent revocation. + /// + public DateTimeOffset? Until { get; init; } + + /// + /// Optional human-readable reason for audit/logging purposes. + /// + public string? Reason { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs new file mode 100644 index 00000000..5a226706 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/AddCredentialResult.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record AddCredentialResult +{ + public bool Succeeded { get; init; } + + public string? Error { get; init; } + + public Guid? Id { get; set; } + public CredentialType? Type { get; init; } + + public static AddCredentialResult Success(Guid id, CredentialType type) + => new() + { + Succeeded = true, + Id = id, + Type = type, + Error = null + }; + + public static AddCredentialResult Fail(string error) + => new() + { + Succeeded = false, + Error = error, + Id = null, + Type = null + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs new file mode 100644 index 00000000..c7f399c8 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/BeginCredentialResetResult.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record BeginCredentialResetResult +{ + public string? Token { get; init; } + public DateTimeOffset ExpiresAt { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs new file mode 100644 index 00000000..6a78c338 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/ChangeCredentialResult.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record ChangeCredentialResult +{ + public bool IsSuccess { get; init; } + + public string? Error { get; init; } + + public CredentialType? Type { get; init; } + + public static ChangeCredentialResult Success(CredentialType type) + => new() + { + IsSuccess = true, + Type = type + }; + + public static ChangeCredentialResult Fail(string error) + => new() + { + IsSuccess = false, + Error = error + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs new file mode 100644 index 00000000..f45a6659 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialActionResult.cs @@ -0,0 +1,21 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialActionResult +{ + public bool Succeeded { get; init; } + + public string? Error { get; init; } + + public static CredentialActionResult Success() + => new() + { + Succeeded = true + }; + + public static CredentialActionResult Fail(string error) + => new() + { + Succeeded = false, + Error = error + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs new file mode 100644 index 00000000..ad00b575 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialChangeResult.cs @@ -0,0 +1,13 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialChangeResult +{ + public bool Succeeded { get; init; } + + /// + /// Indicates whether security version / sessions were invalidated. + /// + public bool SecurityInvalidated { get; init; } + + public string? FailureReason { get; init; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs new file mode 100644 index 00000000..c116b8e6 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialProvisionResult.cs @@ -0,0 +1,41 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialProvisionResult +{ + public required bool Succeeded { get; init; } + + public CredentialType? Type { get; init; } + + /// + /// Indicates whether existing security state was affected. + /// For initial provisioning this is usually false. + /// + public bool SecurityInvalidated { get; init; } + + public string? FailureReason { get; init; } + + public static CredentialProvisionResult Success(CredentialType type) + => new() + { + Succeeded = true, + Type = type, + SecurityInvalidated = false + }; + + public static CredentialProvisionResult AlreadyExists(CredentialType type) + => new() + { + Succeeded = true, + Type = type, + SecurityInvalidated = false + }; + + public static CredentialProvisionResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs new file mode 100644 index 00000000..8cb7ea61 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/CredentialValidationResult.cs @@ -0,0 +1,36 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record CredentialValidationResult +{ + public bool IsValid { get; init; } + public bool RequiresReauthentication { get; init; } + public bool RequiresSecurityVersionIncrement { get; init; } + public string? FailureReason { get; init; } + + public static CredentialValidationResult Success( + bool requiresSecurityVersionIncrement = false) + => new() + { + IsValid = true, + RequiresSecurityVersionIncrement = requiresSecurityVersionIncrement + }; + + public static CredentialValidationResult Failed( + string? reason = null, + bool requiresReauthentication = false) + => new() + { + IsValid = false, + RequiresReauthentication = requiresReauthentication, + FailureReason = reason + }; + + public static CredentialValidationResult ReauthenticationRequired( + string? reason = null) + => new() + { + IsValid = false, + RequiresReauthentication = true, + FailureReason = reason + }; +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs new file mode 100644 index 00000000..8a8a5315 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/Responses/GetCredentialsResult.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials.Contracts; + +public sealed record GetCredentialsResult +{ + public IReadOnlyCollection Credentials { get; init; } = Array.Empty(); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/logo.png b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/credentials/CodeBeam.UltimateAuth.Credentials.Contracts/logo.png differ diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj new file mode 100644 index 00000000..9ed75d81 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore + + + Entity Framework Core persistence implementation for UltimateAuth Credentials module. + Provides secure and durable credential storage for production environments. + + + authentication;credentials;efcore;password;database;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs new file mode 100644 index 00000000..fb5a975d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialDbContext.cs @@ -0,0 +1,18 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public sealed class UAuthCredentialDbContext : DbContext +{ + public DbSet PasswordCredentials => Set(); + + public UAuthCredentialDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UAuthCredentialsModelBuilder.Configure(modelBuilder); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs new file mode 100644 index 00000000..67415b62 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Data/UAuthCredentialsModelBuilder.cs @@ -0,0 +1,60 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public static class UAuthCredentialsModelBuilder +{ + public static void Configure(ModelBuilder b) + { + ConfigurePasswordCredentials(b); + } + + private static void ConfigurePasswordCredentials(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_PasswordCredentials"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.SecretHash) + .HasMaxLength(512) + .IsRequired(); + + e.Property(x => x.SecurityStamp) + .IsRequired(); + + e.Property(x => x.Source) + .HasMaxLength(128); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.RevokedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.ExpiresAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.LastUsedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.HasIndex(x => new { x.Tenant, x.Id }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.DeletedAt }); + e.HasIndex(x => new { x.Tenant, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + }); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..7c390a43 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthCredentialsEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext + { + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + return services; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs new file mode 100644 index 00000000..8d91b84b --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Mappers/PasswordCredentialProjectionMapper.cs @@ -0,0 +1,72 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal static class PasswordCredentialProjectionMapper +{ + public static PasswordCredential ToDomain(this PasswordCredentialProjection p) + { + var security = new CredentialSecurityState( + revokedAt: p.RevokedAt, + expiresAt: p.ExpiresAt, + securityStamp: p.SecurityStamp); + + var metadata = new CredentialMetadata + { + LastUsedAt = p.LastUsedAt, + Source = p.Source + }; + + return PasswordCredential.FromProjection( + id: p.Id, + tenant: p.Tenant, + userKey: p.UserKey, + secretHash: p.SecretHash, + security: security, + metadata: metadata, + createdAt: p.CreatedAt, + updatedAt: p.UpdatedAt, + deletedAt: p.DeletedAt, + version: p.Version + ); + } + + public static PasswordCredentialProjection ToProjection(this PasswordCredential c) + { + return new PasswordCredentialProjection + { + Id = c.Id, + Tenant = c.Tenant, + UserKey = c.UserKey, + SecretHash = c.SecretHash, + + RevokedAt = c.Security.RevokedAt, + ExpiresAt = c.Security.ExpiresAt, + SecurityStamp = c.Security.SecurityStamp, + + LastUsedAt = c.Metadata.LastUsedAt, + Source = c.Metadata.Source, + + CreatedAt = c.CreatedAt, + UpdatedAt = c.UpdatedAt, + DeletedAt = c.DeletedAt, + Version = c.Version + }; + } + + public static void UpdateProjection(this PasswordCredential c, PasswordCredentialProjection p) + { + p.SecretHash = c.SecretHash; + + p.RevokedAt = c.Security.RevokedAt; + p.ExpiresAt = c.Security.ExpiresAt; + p.SecurityStamp = c.Security.SecurityStamp; + + p.LastUsedAt = c.Metadata.LastUsedAt; + p.Source = c.Metadata.Source; + + p.UpdatedAt = c.UpdatedAt; + p.DeletedAt = c.DeletedAt; + } +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs new file mode 100644 index 00000000..ae4a8dee --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Projections/PasswordCredentialProjection.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +public sealed class PasswordCredentialProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } + + public string SecretHash { get; set; } = default!; + + public DateTimeOffset? RevokedAt { get; set; } + + public DateTimeOffset? ExpiresAt { get; set; } + + public Guid SecurityStamp { get; set; } + + public DateTimeOffset? LastUsedAt { get; set; } + + public string? Source { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/README.md b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/README.md new file mode 100644 index 00000000..561e902f --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Credentials EntityFrameworkCore + +Entity Framework Core persistence implementation for UltimateAuth Credentials module. + +## Purpose + +Provides durable credential storage for: + +- Password authentication +- Credential validation +- Credential lifecycle management + +## Features + +- Persistent credential storage +- Secure password handling +- Scalable architecture + +## Notes + +- Requires EF Core setup +- Migrations handled by application + +## When to use + +- Production environments + +## Alternatives + +- CodeBeam.UltimateAuth.Credentials.InMemory +- Custom packages \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs new file mode 100644 index 00000000..383bd7e0 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStore.cs @@ -0,0 +1,164 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordCredentialStore : IPasswordCredentialStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCorePasswordCredentialStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task ExistsAsync(CredentialKey key, CancellationToken ct = default) + { + return await DbSet + .AnyAsync(x => + x.Id == key.Id && + x.Tenant == _tenant, + ct); + } + + public async Task AddAsync(PasswordCredential credential, CancellationToken ct = default) + { + var entity = credential.ToProjection(); + + DbSet.Add(entity); + + await _db.SaveChangesAsync(ct); + } + + public async Task GetAsync(CredentialKey key, CancellationToken ct = default) + { + var entity = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Id == key.Id && + x.Tenant == _tenant, + ct); + + return entity?.ToDomain(); + } + + public async Task SaveAsync(PasswordCredential credential, long expectedVersion, CancellationToken ct = default) + { + var entity = await DbSet + .SingleOrDefaultAsync(x => + x.Id == credential.Id && + x.Tenant == _tenant, + ct); + + if (entity is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("credential_version_conflict"); + + credential.UpdateProjection(entity); + entity.Version++; + + await _db.SaveChangesAsync(ct); + } + + public async Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) + { + var entity = await DbSet + .SingleOrDefaultAsync(x => + x.Id == key.Id && + x.Tenant == _tenant, + ct); + + if (entity is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("credential_version_conflict"); + + var domain = entity.ToDomain().Revoke(revokedAt); + domain.UpdateProjection(entity); + + entity.Version++; + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(CredentialKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var entity = await DbSet + .SingleOrDefaultAsync(x => + x.Id == key.Id && + x.Tenant == _tenant, + ct); + + if (entity is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (entity.Version != expectedVersion) + throw new UAuthConcurrencyException("credential_version_conflict"); + + if (mode == DeleteMode.Hard) + { + DbSet.Remove(entity); + } + else + { + var domain = entity.ToDomain().MarkDeleted(now); + domain.UpdateProjection(entity); + entity.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) + { + var entities = await DbSet + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .ToListAsync(ct); + + return entities + .Select(x => x.ToDomain()) + .ToList() + .AsReadOnly(); + } + + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + if (mode == DeleteMode.Hard) + { + await DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + + return; + } + + await DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .ExecuteUpdateAsync(x => + x + .SetProperty(c => c.DeletedAt, now) + .SetProperty(c => c.Version, c => c.Version + 1), + ct); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs new file mode 100644 index 00000000..13a0a4a7 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/Stores/EfCorePasswordCredentialStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; + +internal sealed class EfCorePasswordCredentialStoreFactory : IPasswordCredentialStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCorePasswordCredentialStoreFactory(TDbContext db) + { + _db = db; + } + + public IPasswordCredentialStore Create(TenantKey tenant) + { + return new EfCorePasswordCredentialStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/logo.png b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/credentials/CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore/logo.png differ diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj new file mode 100644 index 00000000..40c7c5ff --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/CodeBeam.UltimateAuth.Credentials.InMemory.csproj @@ -0,0 +1,32 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Credentials.InMemory + + + In-memory persistence implementation for UltimateAuth Credentials module. + Provides lightweight credential storage for development and testing scenarios. + Not suitable for production environments. + + + authentication;credentials;inmemory;password;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs new file mode 100644 index 00000000..0d1d7981 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStore.cs @@ -0,0 +1,67 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +internal sealed class InMemoryPasswordCredentialStore : InMemoryTenantVersionedStore, IPasswordCredentialStore +{ + protected override CredentialKey GetKey(PasswordCredential entity) + => new(entity.Tenant, entity.Id); + + public InMemoryPasswordCredentialStore(TenantContext tenant) : base(tenant) + { + } + + protected override void BeforeAdd(PasswordCredential entity) + { + var exists = TenantValues() + .Any(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + !x.IsDeleted); + + if (exists) + throw new UAuthConflictException("password_credential_exists"); + } + + public Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = TenantValues() + .Where(x => + x.UserKey == userKey && + !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } + + public Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default) + { + if (!TryGet(key, out var credential) || credential is null) + throw new UAuthNotFoundException("credential_not_found"); + + var revoked = credential.Revoke(revokedAt); + return SaveAsync(revoked, expectedVersion, ct); + } + + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + var credentials = TenantValues() + .Where(c => c.UserKey == userKey) + .ToList(); + + foreach (var credential in credentials) + { + await DeleteAsync(GetKey(credential), credential.Version, mode, now, ct); + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs new file mode 100644 index 00000000..6258724a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/InMemoryPasswordCredentialStoreFactory.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Reference; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory; + +public sealed class InMemoryPasswordCredentialStoreFactory : IPasswordCredentialStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IPasswordCredentialStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => new InMemoryPasswordCredentialStore(new TenantContext(t))); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/README.md b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/README.md new file mode 100644 index 00000000..8d87a1a4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Credentials InMemory + +In-memory persistence implementation for the UltimateAuth Credentials module. + +## Purpose + +Provides lightweight credential storage for: + +- Password-based authentication +- Credential validation +- Credential lifecycle operations + +## When to use + +- Development +- Testing +- Prototyping + +## โš ๏ธ Not for production + +Credentials are stored in memory and will be lost when the application restarts. + +## Notes + +- No external dependencies +- Zero configuration required + +## Use instead (production) + +- CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +- Custom credential persistence \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..9b53af9c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Reference; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Credentials.InMemory.Extensions +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddUltimateAuthCredentialsInMemory(this IServiceCollection services) + { + services.TryAddSingleton(); + + return services; + } + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/logo.png b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/credentials/CodeBeam.UltimateAuth.Credentials.InMemory/logo.png differ diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs new file mode 100644 index 00000000..156a21be --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.Reference")] diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj new file mode 100644 index 00000000..1b75f477 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/CodeBeam.UltimateAuth.Credentials.Reference.csproj @@ -0,0 +1,30 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Credentials.Reference + + + Default reference implementation for the UltimateAuth Credentials module. + Provides credential management features such as password handling, secret rotation and credential lifecycle operations. + + + authentication;credentials;password;security;reference;auth-framework + logo.png + README.md + + + + + + + + + + + + + + diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs new file mode 100644 index 00000000..6e24cb6c --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Domain/PasswordCredential.cs @@ -0,0 +1,179 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class PasswordCredential : ISecretCredential, ITenantEntity, IVersionedEntity, IEntitySnapshot, ISoftDeletable +{ + public Guid Id { get; init; } + public TenantKey Tenant { get; init; } + public UserKey UserKey { get; init; } + public CredentialType Type => CredentialType.Password; + + // TODO: Add hash algorithm (PasswordHash object with hash and algorithm properties) + public string SecretHash { get; private set; } = default!; + public CredentialSecurityState Security { get; private set; } = CredentialSecurityState.Active(); + public CredentialMetadata Metadata { get; private set; } = new CredentialMetadata(); + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsRevoked => Security.RevokedAt is not null; + public bool IsDeleted => DeletedAt is not null; + public bool IsExpired(DateTimeOffset now) => Security.ExpiresAt is not null && Security.ExpiresAt <= now; + + private PasswordCredential() { } + + private PasswordCredential( + Guid id, + TenantKey tenant, + UserKey userKey, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + if (id == Guid.Empty) + throw new UAuthValidationException("credential_id_required"); + + if (string.IsNullOrWhiteSpace(secretHash)) + throw new UAuthValidationException("credential_secret_required"); + + Id = id; + Tenant = tenant; + UserKey = userKey; + SecretHash = secretHash; + Security = security ?? CredentialSecurityState.Active(); + Metadata = metadata ?? new CredentialMetadata(); + CreatedAt = createdAt; + UpdatedAt = updatedAt; + DeletedAt = deletedAt; + Version = version; + } + + public PasswordCredential Snapshot() + { + return new PasswordCredential + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + SecretHash = SecretHash, + Security = Security, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + Version = Version + }; + } + + public static PasswordCredential Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata, + DateTimeOffset now) + { + return new PasswordCredential( + id ?? Guid.NewGuid(), + tenant, + userKey, + secretHash, + security ?? CredentialSecurityState.Active(), + metadata ?? new CredentialMetadata(), + now, + null, + null, + 0); + } + + public PasswordCredential ChangeSecret(string newSecretHash, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(newSecretHash)) + throw new UAuthValidationException("credential_secret_required"); + + if (IsRevoked) + throw new UAuthConflictException("credential_revoked"); + + if (IsExpired(now)) + throw new UAuthConflictException("credential_expired"); + + if (string.Equals(SecretHash, newSecretHash, StringComparison.Ordinal)) + throw new UAuthValidationException("credential_secret_same"); + + SecretHash = newSecretHash; + Security = Security.RotateStamp(); + UpdatedAt = now; + + return this; + } + + public PasswordCredential SetExpiry(DateTimeOffset? expiresAt, DateTimeOffset now) + { + if (IsExpired(now)) + return this; + + Security = Security.SetExpiry(expiresAt); + UpdatedAt = now; + + return this; + } + + public PasswordCredential Revoke(DateTimeOffset now) + { + if (IsRevoked) + return this; + + Security = Security.Revoke(now); + UpdatedAt = now; + + return this; + } + + public PasswordCredential MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } + + public static PasswordCredential FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + string secretHash, + CredentialSecurityState security, + CredentialMetadata metadata, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new PasswordCredential( + id, + tenant, + userKey, + secretHash, + security, + metadata, + createdAt, + updatedAt, + deletedAt, + version); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs new file mode 100644 index 00000000..cd214a18 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Endpoints/CredentialEndpointHandler.cs @@ -0,0 +1,260 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public sealed class CredentialEndpointHandler : ICredentialEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly ICredentialManagementService _credentials; + + public CredentialEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, ICredentialManagementService credentials) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _credentials = credentials; + } + + public async Task GetAllAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ListSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); + + var result = await _credentials.GetAllAsync(accessContext, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task AddAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.AddSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); + + var result = await _credentials.AddAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task ChangeSecretAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ChangeSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); + + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeAsync(HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.RevokeSelf, + resource: "credentials", + resourceId: flow.UserKey!.Value); + + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); + return Results.NoContent(); + } + + public async Task BeginResetAsync(HttpContext ctx) + { + // Don't call TryGetSelf here, as the user might be locked out and thus not authenticated. + var flow = _authFlow.Current; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.BeginResetAnonymous, + resource: "credentials", + resourceId: request.Identifier); + + var result = await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task CompleteResetAsync(HttpContext ctx) + { + // Don't call TryGetSelf here, as the user might be locked out and thus not authenticated. + var flow = _authFlow.Current; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.CompleteResetAnonymous, + resource: "credentials", + resourceId: request.Identifier); + + var result = await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task GetAllAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ListAdmin, + resource: "credentials", + resourceId: userKey.Value); + + var result = await _credentials.GetAllAsync(accessContext, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task AddAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.AddAdmin, + resource: "credentials", + resourceId: userKey.Value); + + var result = await _credentials.AddAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task ChangeSecretAdminAsync(UserKey userKey, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.ChangeAdmin, + resource: "credentials", + resourceId: userKey.Value); + + var result = await _credentials.ChangeSecretAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(result); + } + + public async Task RevokeAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.RevokeAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.RevokeAsync(accessContext, request, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task DeleteAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.DeleteAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.DeleteAsync(accessContext, request, ctx.RequestAborted); + + return Results.NoContent(); + } + + public async Task BeginResetAdminAsync(UserKey userKey, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.BeginResetAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.BeginResetAsync(accessContext, request, ctx.RequestAborted); + return Results.NoContent(); + } + + public async Task CompleteResetAdminAsync(UserKey userKey, HttpContext ctx) + { + if (!TryGetSelf(out var flow, out var error)) + return error!; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + flow, + action: UAuthActions.Credentials.CompleteResetAdmin, + resource: "credentials", + resourceId: userKey.Value); + + await _credentials.CompleteResetAsync(accessContext, request, ctx.RequestAborted); + return Results.NoContent(); + } + + private bool TryGetSelf(out AuthFlowContext flow, out IResult? error) + { + flow = _authFlow.Current; + if (!flow.IsAuthenticated || flow.UserKey is null) + { + error = Results.Unauthorized(); + return false; + } + + error = null; + return true; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..b7c8ae8a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Credentials.Reference.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthCredentialsReference(this IServiceCollection services) + { + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs new file mode 100644 index 00000000..4f5dac13 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordCredentialProvider.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordCredentialProvider : ICredentialProvider +{ + private readonly IPasswordCredentialStoreFactory _storeFactory; + private readonly ICredentialValidator _validator; + + public CredentialType Type => CredentialType.Password; + + public PasswordCredentialProvider(IPasswordCredentialStoreFactory storeFactory, ICredentialValidator validator) + { + _storeFactory = storeFactory; + _validator = validator; + } + + public async Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenant); + var creds = await store.GetByUserAsync(userKey, ct); + return creds.Cast().ToList(); + } + + public async Task ValidateAsync(ICredential credential, string secret, CancellationToken ct = default) + { + var result = await _validator.ValidateAsync(credential, secret, ct); + return result.IsValid; + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs new file mode 100644 index 00000000..918ea9c4 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Infrastructure/PasswordUserLifecycleIntegration.cs @@ -0,0 +1,52 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +internal sealed class PasswordUserLifecycleIntegration : IUserLifecycleIntegration +{ + private readonly IPasswordCredentialStoreFactory _credentialStoreFactory; + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IClock _clock; + + public PasswordUserLifecycleIntegration(IPasswordCredentialStoreFactory credentialStoreFactory, IUAuthPasswordHasher passwordHasher, IClock clock) + { + _credentialStoreFactory = credentialStoreFactory; + _passwordHasher = passwordHasher; + _clock = clock; + } + + public async Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct) + { + if (request is not CreateUserRequest r) + return; + + if (string.IsNullOrWhiteSpace(r.Password)) + return; + + var hash = _passwordHasher.Hash(r.Password); + + var credential = PasswordCredential.Create( + id: null, + tenant: tenant, + userKey: userKey, + secretHash: hash, + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata { }, + _clock.UtcNow); + + var credentialStore = _credentialStoreFactory.Create(tenant); + await credentialStore.AddAsync(credential, ct); + } + + public async Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct) + { + var credentialStore = _credentialStoreFactory.Create(tenant); + await credentialStore.DeleteByUserAsync(userKey, mode, _clock.UtcNow, ct); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs new file mode 100644 index 00000000..05035c5e --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Internal/IUserCredentialsInternalService.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference.Internal; + +internal interface IUserCredentialsInternalService +{ + Task DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/README.md b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/README.md new file mode 100644 index 00000000..a3aa38bc --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/README.md @@ -0,0 +1,40 @@ +๏ปฟ# UltimateAuth Credentials Reference + +Default reference implementation for the UltimateAuth Credentials module. + +## Purpose + +This package provides a ready-to-use implementation of credential management: + +- Password-based authentication +- Secret rotation +- Credential lifecycle operations + +## Usage + +This package is automatically integrated when used with: + +- CodeBeam.UltimateAuth.Server + +No additional configuration is required. + +## โš ๏ธ Important + +This is a reference implementation. + +You are free to: + +- Replace it partially or completely +- Implement your own credential system +- Integrate external identity providers + +## Architecture Notes + +This package currently depends on the server runtime. + +Future versions will move towards a fully decoupled plugin architecture. + +## When to NOT use this package + +- When integrating external auth providers (Auth0, Azure AD, etc.) +- When building a custom credential system \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs new file mode 100644 index 00000000..71d051d6 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/CredentialManagementService.cs @@ -0,0 +1,386 @@ +๏ปฟ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.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.Reference.Internal; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +// TODO: Add unlock credential factor as admin action. +internal sealed class CredentialManagementService : ICredentialManagementService, IUserCredentialsInternalService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IPasswordCredentialStoreFactory _credentialsFactory; + private readonly IAuthenticationSecurityManager _authenticationSecurityManager; + private readonly IOpaqueTokenGenerator _tokenGenerator; + private readonly INumericCodeGenerator _numericCodeGenerator; + private readonly IUAuthPasswordHasher _hasher; + private readonly ITokenHasher _tokenHasher; + private readonly ILoginIdentifierResolver _identifierResolver; + private readonly ISessionStoreFactory _sessionFactory; + private readonly UAuthServerOptions _options; + private readonly IClock _clock; + + public CredentialManagementService( + IAccessOrchestrator accessOrchestrator, + IPasswordCredentialStoreFactory credentialsFactory, + IAuthenticationSecurityManager authenticationSecurityManager, + IOpaqueTokenGenerator tokenGenerator, + INumericCodeGenerator numericCodeGenerator, + IUAuthPasswordHasher hasher, + ITokenHasher tokenHasher, + ILoginIdentifierResolver identifierResolver, + ISessionStoreFactory sessionFactory, + IOptions options, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _credentialsFactory = credentialsFactory; + _authenticationSecurityManager = authenticationSecurityManager; + _tokenGenerator = tokenGenerator; + _numericCodeGenerator = numericCodeGenerator; + _hasher = hasher; + _tokenHasher = tokenHasher; + _identifierResolver = identifierResolver; + _sessionFactory = sessionFactory; + _options = options.Value; + _clock = clock; + } + + public async Task GetAllAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + var store = _credentialsFactory.Create(context.ResourceTenant); + var credentials = await store.GetByUserAsync(subjectUser, innerCt); + + var dtos = credentials + .Select(c => new CredentialInfo + { + Id = c.Id, + Type = c.Type, + Status = c.Security.Status(now), + ExpiresAt = c.Security.ExpiresAt, + RevokedAt = c.Security.RevokedAt, + LastUsedAt = c.Metadata.LastUsedAt, + Source = c.Metadata.Source, + Version = c.Version, + }) + .ToArray(); + + return new GetCredentialsResult { Credentials = dtos }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var hash = _hasher.Hash(request.Secret); + + var credential = PasswordCredential.Create( + id: null, + tenant: context.ResourceTenant, + userKey: subjectUser, + secretHash: hash, + security: CredentialSecurityState.Active(), + metadata: new CredentialMetadata(), + now: now); + + var store = _credentialsFactory.Create(context.ResourceTenant); + await store.AddAsync(credential, innerCt); + + return AddCredentialResult.Success(credential.Id, credential.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var store = _credentialsFactory.Create(context.ResourceTenant); + var credentials = await store.GetByUserAsync(subjectUser, innerCt); + var pwd = credentials.OfType().Where(c => c.Security.IsUsable(now)).FirstOrDefault(); + + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (pwd.UserKey != subjectUser) + throw new UAuthNotFoundException("credential_not_found"); + + if (context.IsSelfAction) + { + if (string.IsNullOrWhiteSpace(request.CurrentSecret)) + throw new UAuthNotFoundException("current_secret_required"); + + if (!_hasher.Verify(pwd.SecretHash, request.CurrentSecret)) + throw new UAuthConflictException("invalid_credentials"); + } + + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); + + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); + await store.SaveAsync(updated, oldVersion, innerCt); + + var sessionStore = _sessionFactory.Create(context.ResourceTenant); + if (context.IsSelfAction && context.ActorChainId is SessionChainId chainId) + { + await sessionStore.RevokeOtherChainsAsync(subjectUser, chainId, now, innerCt); + } + else + { + await sessionStore.RevokeAllChainsAsync(subjectUser, now, innerCt); + } + + return ChangeCredentialResult.Success(pwd.Type); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var store = _credentialsFactory.Create(context.ResourceTenant); + var credential = await store.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + var updated = pwd.Revoke(now); + await store.SaveAsync(updated, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new UAuthValidationException("identifier_required"); + + var now = _clock.UtcNow; + var validity = request.Validity ?? _options.ResetCredential.TokenValidity; + + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); + + if (resolution?.UserKey is not UserKey userKey) + { + return new BeginCredentialResetResult + { + Token = null, + ExpiresAt = now.Add(validity) + }; + } + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + + string token; + + if (request.ResetCodeType == ResetCodeType.Token) + { + token = _tokenGenerator.Generate(); + } + else if (request.ResetCodeType == ResetCodeType.Code) + { + token = _numericCodeGenerator.Generate(_options.ResetCredential.CodeLength); + } + else + { + throw new UAuthValidationException("invalid_reset_code_type"); + } + + var tokenHash = _tokenHasher.Hash(token); + + var updatedState = state.BeginReset(tokenHash, now, validity); + await _authenticationSecurityManager.UpdateAsync(updatedState, state.SecurityVersion, innerCt); + + return new BeginCredentialResetResult + { + Token = token, + ExpiresAt = now.Add(validity) + }; + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CompleteResetAsync(AccessContext context, CompleteResetCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + if (string.IsNullOrWhiteSpace(request.Identifier)) + throw new UAuthValidationException("identifier_required"); + + if (string.IsNullOrWhiteSpace(request.ResetToken)) + throw new UAuthValidationException("reset_token_required"); + + if (string.IsNullOrWhiteSpace(request.NewSecret)) + throw new UAuthValidationException("new_secret_required"); + + var now = _clock.UtcNow; + + var resolution = await _identifierResolver.ResolveAsync(context.ResourceTenant, request.Identifier, innerCt); + + if (resolution?.UserKey is not UserKey userKey) + { + // Enumeration protection + return CredentialActionResult.Success(); + } + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, request.CredentialType, innerCt); + + if (!state.HasActiveReset(now)) + throw new UAuthConflictException("reset_request_not_active"); + + if (state.IsResetExpired(now)) + { + var version2 = state.SecurityVersion; + var cleared = state.ClearReset(); + await _authenticationSecurityManager.UpdateAsync(cleared, version2, innerCt); + throw new UAuthConflictException("reset_expired"); + } + + if (!_tokenHasher.Verify(state.ResetTokenHash!, request.ResetToken)) + { + var version = state.SecurityVersion; + var failed = state.RegisterResetFailure(now, _options.ResetCredential.MaxAttempts); + await _authenticationSecurityManager.UpdateAsync(failed, version, innerCt); + throw new UAuthConflictException("invalid_reset_token"); + } + + var store = _credentialsFactory.Create(context.ResourceTenant); + var credentials = await store.GetByUserAsync(userKey, innerCt); + var pwd = credentials.OfType().FirstOrDefault(c => c.Security.IsUsable(now)); + + if (pwd is null) + throw new UAuthNotFoundException("credential_not_found"); + + if (_hasher.Verify(pwd.SecretHash, request.NewSecret)) + throw new UAuthValidationException("credential_secret_same"); + + var version3 = state.SecurityVersion; + state = state.ConsumeReset(now); + await _authenticationSecurityManager.UpdateAsync(state, version3, innerCt); + + var oldVersion = pwd.Version; + var newHash = _hasher.Hash(request.NewSecret); + var updated = pwd.ChangeSecret(newHash, now); + + await store.SaveAsync(updated, oldVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task CancelResetAsync(AccessContext context, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + + var state = await _authenticationSecurityManager + .GetOrCreateFactorAsync(context.ResourceTenant, userKey, CredentialType.Password, innerCt); + + if (!state.HasActiveReset(_clock.UtcNow)) + return CredentialActionResult.Success(); + + var updated = state.ClearReset(); + await _authenticationSecurityManager.UpdateAsync(updated, state.SecurityVersion, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + public async Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var cmd = new AccessCommand(async innerCt => + { + var subjectUser = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var store = _credentialsFactory.Create(context.ResourceTenant); + var credential = await store.GetAsync(new CredentialKey(context.ResourceTenant, request.Id), innerCt); + + if (credential is not PasswordCredential pwd) + return CredentialActionResult.Fail("credential_not_found"); + + if (pwd.UserKey != subjectUser) + return CredentialActionResult.Fail("credential_not_found"); + + var oldVersion = pwd.Version; + await store.DeleteAsync(new CredentialKey(context.ResourceTenant, pwd.Id), oldVersion, request.Mode, now, innerCt); + + return CredentialActionResult.Success(); + }); + + return await _accessOrchestrator.ExecuteAsync(context, cmd, ct); + } + + // ---------------------------------------- + // INTERNAL ONLY - NEVER CALL THEM DIRECTLY + // ---------------------------------------- + async Task IUserCredentialsInternalService.DeleteInternalAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + { + ct.ThrowIfCancellationRequested(); + + var store = _credentialsFactory.Create(tenant); + await store.DeleteByUserAsync(userKey, DeleteMode.Soft, _clock.UtcNow, ct); + return CredentialActionResult.Success(); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs new file mode 100644 index 00000000..b4ab7ec1 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialAuthenticationService.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +/// +/// Orchestrates an authentication attempt against a credential type. +/// Responsible for applying lockout policy by mutating the credential aggregate +/// and persisting it. +/// +public interface ICredentialAuthenticationService +{ + //Task AuthenticateAsync( + // AccessContext context, + // CredentialType type, + // CredentialAuthenticationRequest request, + // CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs new file mode 100644 index 00000000..4816c180 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Services/ICredentialManagementService.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface ICredentialManagementService +{ + Task GetAllAsync(AccessContext context, CancellationToken ct = default); + + Task AddAsync(AccessContext context, AddCredentialRequest request, CancellationToken ct = default); + + Task ChangeSecretAsync(AccessContext context, ChangeCredentialRequest request, CancellationToken ct = default); + + Task RevokeAsync(AccessContext context, RevokeCredentialRequest request, CancellationToken ct = default); + + Task BeginResetAsync(AccessContext context, BeginResetCredentialRequest request, CancellationToken ct = default); + + Task CompleteResetAsync(AccessContext context, CompleteResetCredentialRequest request, CancellationToken ct = default); + + Task DeleteAsync(AccessContext context, DeleteCredentialRequest request, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStore.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStore.cs new file mode 100644 index 00000000..3a044eec --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStore.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordCredentialStore : IVersionedStore +{ + Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default); + Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default); + Task RevokeAsync(CredentialKey key, DateTimeOffset revokedAt, long expectedVersion, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStoreFactory.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStoreFactory.cs new file mode 100644 index 00000000..4ab1ef83 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/Stores/IPasswordCredentialStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials.Reference; + +public interface IPasswordCredentialStoreFactory +{ + IPasswordCredentialStore Create(TenantKey tenant); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/logo.png b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/credentials/CodeBeam.UltimateAuth.Credentials.Reference/logo.png differ diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs new file mode 100644 index 00000000..71b81e12 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredential.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredential +{ + Guid Id { get; } + TenantKey Tenant { get; } + UserKey UserKey { get; } + CredentialType Type { get; } + + CredentialSecurityState Security { get; } + CredentialMetadata Metadata { get; } + + DateTimeOffset CreatedAt { get; } + DateTimeOffset? UpdatedAt { get; } + DateTimeOffset? DeletedAt { get; } + + long Version { get; } + + bool IsDeleted { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs new file mode 100644 index 00000000..171a7805 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialProvider.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialProvider +{ + CredentialType Type { get; } + + Task> GetByUserAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); + + Task ValidateAsync(ICredential credential, string secret, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs new file mode 100644 index 00000000..9f1ba47a --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ICredentialValidator.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public interface ICredentialValidator +{ + Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default); +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs new file mode 100644 index 00000000..10969514 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/IPublicKeyCredential.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials; + +public interface IPublicKeyCredential : ICredential +{ + byte[] PublicKey { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs new file mode 100644 index 00000000..314d8395 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Abstractions/ISecretCredential.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Credentials; + +public interface ISecretCredential : ICredential +{ + string SecretHash { get; } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj new file mode 100644 index 00000000..054b6d8d --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/CodeBeam.UltimateAuth.Credentials.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Credentials + + + Credentials module for UltimateAuth. + Provides orchestration, abstractions and dependency injection wiring for credential management functionality. + Use with a persistence provider such as EntityFrameworkCore or InMemory. + This package is included transitively by CodeBeam.UltimateAuth.Server and usually does not need to be installed directly. + + + authentication;credentials;identity;security;module;auth-framework + logo.png + README.md + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs new file mode 100644 index 00000000..e55164ab --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/Infrastructure/CredentialValidator.cs @@ -0,0 +1,40 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Credentials.Contracts; + +namespace CodeBeam.UltimateAuth.Credentials; + +public sealed class CredentialValidator : ICredentialValidator +{ + private readonly IUAuthPasswordHasher _passwordHasher; + private readonly IClock _clock; + + public CredentialValidator(IUAuthPasswordHasher passwordHasher, IClock clock) + { + _passwordHasher = passwordHasher; + _clock = clock; + } + + public Task ValidateAsync(ICredential credential, string providedSecret, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (credential is ICredential securable) + { + if (!securable.Security.IsUsable(_clock.UtcNow)) + { + return Task.FromResult(CredentialValidationResult.Failed(reason: "credential_not_usable")); + } + } + + if (credential is ISecretCredential secret) + { + var ok = _passwordHasher.Verify(secret.SecretHash, providedSecret); + + return Task.FromResult(ok + ? CredentialValidationResult.Success() + : CredentialValidationResult.Failed(reason: "invalid_credentials")); + } + + return Task.FromResult(CredentialValidationResult.Failed(reason: "unsupported_credential_type")); + } +} diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/README.md b/src/credentials/CodeBeam.UltimateAuth.Credentials/README.md new file mode 100644 index 00000000..82e95677 --- /dev/null +++ b/src/credentials/CodeBeam.UltimateAuth.Credentials/README.md @@ -0,0 +1,22 @@ +๏ปฟ# UltimateAuth Credentials + +Credential management module for UltimateAuth. + +## Purpose + +This package provides: + +- Dependency injection setup +- Credential module orchestration +- Integration points for credential providers + +## Does NOT include + +- Persistence (use EntityFrameworkCore or InMemory packages) +- Domain implementation (use Reference package if needed) + +โš ๏ธ This package is typically installed transitively via: + +- CodeBeam.UltimateAuth.Server + +In most cases, you do not need to install it directly unless you are building custom integrations. \ No newline at end of file diff --git a/src/credentials/CodeBeam.UltimateAuth.Credentials/logo.png b/src/credentials/CodeBeam.UltimateAuth.Credentials/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/credentials/CodeBeam.UltimateAuth.Credentials/logo.png differ diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj new file mode 100644 index 00000000..f563f619 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/CodeBeam.UltimateAuth.EntityFrameworkCore.csproj @@ -0,0 +1,42 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.EntityFrameworkCore + + + Shared Entity Framework Core infrastructure for UltimateAuth. + Provides base implementations, conventions and utilities used by UltimateAuth EF Core persistence providers. + This package is not intended to be used directly in applications. + + + authentication;efcore;infrastructure;orm;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs new file mode 100644 index 00000000..dd6adbd5 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/AuthSessionIdEfConverter.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class AuthSessionIdEfConverter +{ + public static AuthSessionId FromDatabase(string raw) + { + if (!AuthSessionId.TryCreate(raw, out var id)) + { + throw new InvalidOperationException($"Invalid AuthSessionId value in database: '{raw}'"); + } + + return id; + } + + public static string ToDatabase(AuthSessionId id) => id.Value; + + public static AuthSessionId? FromDatabaseNullable(string? raw) + { + if (raw is null) + return null; + + if (!AuthSessionId.TryCreate(raw, out var id)) + { + throw new InvalidOperationException($"Invalid AuthSessionId value in database: '{raw}'"); + } + + return id; + } + + public static string? ToDatabaseNullable(AuthSessionId? id) => id?.Value; +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/DateTimeOffsetConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/DateTimeOffsetConverter.cs new file mode 100644 index 00000000..6fd9019e --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/DateTimeOffsetConverter.cs @@ -0,0 +1,31 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Metadata.Builders; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public static class DateTimeOffsetConverter +{ + public static PropertyBuilder HasUtcDateTimeOffsetConverter(this PropertyBuilder property) + { + return property.HasConversion(UtcDateTimeOffsetConverter); + } + + public static PropertyBuilder HasNullableUtcDateTimeOffsetConverter(this PropertyBuilder property) + { + return property.HasConversion(NullableUtcDateTimeOffsetConverter); + } + + private static readonly ValueConverter UtcDateTimeOffsetConverter = + new( + v => v.UtcDateTime, + v => new DateTimeOffset(DateTime.SpecifyKind(v, DateTimeKind.Utc)) + ); + + private static readonly ValueConverter NullableUtcDateTimeOffsetConverter = + new( + v => v.HasValue ? v.Value.UtcDateTime : null, + v => v.HasValue + ? new DateTimeOffset(DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) + : null + ); +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonSerializeWrapper.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonSerializeWrapper.cs new file mode 100644 index 00000000..52bb84c2 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonSerializeWrapper.cs @@ -0,0 +1,28 @@ +๏ปฟusing System.Text.Json; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class JsonSerializerWrapper +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public static string Serialize(T value) + { + return JsonSerializer.Serialize(value, Options); + } + + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, Options)!; + } + + public static string? SerializeNullable(T? value) + { + return value is null ? null : JsonSerializer.Serialize(value, Options); + } + + public static T? DeserializeNullable(string? json) + { + return json is null ? default : JsonSerializer.Deserialize(json, Options); + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueComparers.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueComparers.cs new file mode 100644 index 00000000..829e1700 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueComparers.cs @@ -0,0 +1,64 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.ChangeTracking; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class JsonValueComparerHelpers +{ + public static bool AreEqual(T left, T right) + { + return string.Equals( + JsonSerializerWrapper.Serialize(left), + JsonSerializerWrapper.Serialize(right), + StringComparison.Ordinal); + } + + public static int GetHashCodeSafe(T value) + { + return JsonSerializerWrapper.Serialize(value).GetHashCode(); + } + + public static T Snapshot(T value) + { + var json = JsonSerializerWrapper.Serialize(value); + return JsonSerializerWrapper.Deserialize(json); + } + + public static bool AreEqualNullable(T? left, T? right) + { + return string.Equals( + JsonSerializerWrapper.SerializeNullable(left), + JsonSerializerWrapper.SerializeNullable(right), + StringComparison.Ordinal); + } + + public static int GetHashCodeSafeNullable(T? value) + { + var json = JsonSerializerWrapper.SerializeNullable(value); + return json == null ? 0 : json.GetHashCode(); + } + + public static T? SnapshotNullable(T? value) + { + var json = JsonSerializerWrapper.SerializeNullable(value); + return json == null ? default : JsonSerializerWrapper.Deserialize(json); + } +} + +public static class JsonValueComparers +{ + public static ValueComparer Create() + { + return new ValueComparer( + (l, r) => JsonValueComparerHelpers.AreEqual(l, r), + v => JsonValueComparerHelpers.GetHashCodeSafe(v), + v => JsonValueComparerHelpers.Snapshot(v)); + } + + public static ValueComparer CreateNullable() + { + return new ValueComparer( + (l, r) => JsonValueComparerHelpers.AreEqualNullable(l, r), + v => JsonValueComparerHelpers.GetHashCodeSafeNullable(v), + v => JsonValueComparerHelpers.SnapshotNullable(v)); + } +} \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs new file mode 100644 index 00000000..44d6293d --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/JsonValueConverter.cs @@ -0,0 +1,18 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class JsonValueConverter : ValueConverter +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public JsonValueConverter() + : base(v => Serialize(v), v => Deserialize(v)) + { + } + + private static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + private static T Deserialize(string json) => JsonSerializer.Deserialize(json, Options)!; +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs new file mode 100644 index 00000000..7972f21a --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableAuthSessionIdConverter.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class AuthSessionIdConverter : ValueConverter +{ + public AuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabase(id), raw => AuthSessionIdEfConverter.FromDatabase(raw)) + { + } +} + +public sealed class NullableAuthSessionIdConverter : ValueConverter +{ + public NullableAuthSessionIdConverter() : base(id => AuthSessionIdEfConverter.ToDatabaseNullable(id), raw => AuthSessionIdEfConverter.FromDatabaseNullable(raw)) + { + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableJsonValueConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableJsonValueConverter.cs new file mode 100644 index 00000000..f1ac4d05 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableJsonValueConverter.cs @@ -0,0 +1,18 @@ +๏ปฟusing Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class NullableJsonValueConverter : ValueConverter +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + + public NullableJsonValueConverter() + : base(v => Serialize(v), v => Deserialize(v)) + { + } + + private static string? Serialize(T? value) => value == null ? null : JsonSerializer.Serialize(value, Options); + + private static T? Deserialize(string? json) => json == null ? default : JsonSerializer.Deserialize(json, Options); +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableSessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableSessionChainIdConverter.cs new file mode 100644 index 00000000..34e22b2e --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/NullableSessionChainIdConverter.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class NullableSessionChainIdConverter : ValueConverter +{ + public NullableSessionChainIdConverter() + : base( + id => SessionChainIdEfConverter.ToDatabaseNullable(id), + raw => SessionChainIdEfConverter.FromDatabaseNullable(raw)) + { + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs new file mode 100644 index 00000000..ff21b301 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdConverter.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +public sealed class SessionChainIdConverter : ValueConverter +{ + public SessionChainIdConverter() + : base( + id => SessionChainIdEfConverter.ToDatabase(id), + raw => SessionChainIdEfConverter.FromDatabase(raw)) + { + } +} \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdEfConverter.cs b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdEfConverter.cs new file mode 100644 index 00000000..14dc9eae --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/Infrastructure/SessionChainIdEfConverter.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.EntityFrameworkCore; + +internal static class SessionChainIdEfConverter +{ + public static SessionChainId FromDatabase(Guid raw) + { + return SessionChainId.From(raw); + } + + public static Guid ToDatabase(SessionChainId id) => id.Value; + + public static SessionChainId? FromDatabaseNullable(Guid? raw) + { + if (raw is null) + return null; + + return SessionChainId.From(raw.Value); + } + + public static Guid? ToDatabaseNullable(SessionChainId? id) => id?.Value; +} diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md new file mode 100644 index 00000000..3e51e5e7 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/README.md @@ -0,0 +1,32 @@ +๏ปฟ# UltimateAuth EntityFrameworkCore Infrastructure + +Shared EF Core infrastructure for UltimateAuth. + +## Purpose + +This package contains common EF Core building blocks used by +UltimateAuth persistence providers: + +- Base DbContext implementations +- Entity configuration helpers +- Common mapping conventions +- Shared persistence utilities + +## โš ๏ธ Important + +This package is NOT intended to be used directly. + +Instead, use one of the following: + +- CodeBeam.UltimateAuth.Users.EntityFrameworkCore +- CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore +- CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore +- CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore +- CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +- CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore + +These packages include this one automatically. + +## When to use directly? + +Only if you are building a custom persistence provider. \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/persistence/CodeBeam.UltimateAuth.EntityFrameworkCore/logo.png differ diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj b/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj new file mode 100644 index 00000000..bf35b115 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/CodeBeam.UltimateAuth.InMemory.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.InMemory + + + Shared in-memory infrastructure for UltimateAuth. + Provides lightweight, thread-safe in-memory store implementations and helpers used by UltimateAuth modules for development and testing scenarios. + Not recommended for production use. + + + authentication;inmemory;testing;cache;auth-framework + logo.png + README.md + + + + + + + + + + + + diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/IInMemoryUserIdProvider.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/IInMemoryUserIdProvider.cs new file mode 100644 index 00000000..a5296452 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/IInMemoryUserIdProvider.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.InMemory; + +public interface IInMemoryUserIdProvider +{ + TUserId GetAdminUserId(); + TUserId GetUserUserId(); +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs new file mode 100644 index 00000000..c3d3d5c3 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryTenantVersionedStore.cs @@ -0,0 +1,51 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.InMemory; + +public abstract class InMemoryTenantVersionedStore : InMemoryVersionedStore + where TEntity : class, IVersionedEntity, IEntitySnapshot, ITenantEntity + where TKey : notnull, IEquatable +{ + private readonly TenantContext _tenant; + + protected InMemoryTenantVersionedStore(TenantContext tenant) + { + _tenant = tenant; + } + + protected override void BeforeAdd(TEntity entity) + { + EnsureTenant(entity); + base.BeforeAdd(entity); + } + + protected override void BeforeSave(TEntity entity, TEntity current, long expectedVersion) + { + EnsureTenant(entity); + base.BeforeSave(entity, current, expectedVersion); + } + + protected override void BeforeDelete(TEntity current, long expectedVersion, DeleteMode mode, DateTimeOffset now) + { + EnsureTenant(current); + base.BeforeDelete(current, expectedVersion, mode, now); + } + + protected IReadOnlyList TenantValues() + { + return InternalValues() + .Where(x => x.Tenant == _tenant.Tenant) + .Select(Snapshot) + .ToList() + .AsReadOnly(); + } + + private void EnsureTenant(TEntity entity) + { + if (!_tenant.IsGlobal && entity.Tenant != _tenant.Tenant) + throw new UAuthConflictException("tenant_mismatch"); + } +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs new file mode 100644 index 00000000..8d705d32 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/InMemoryVersionedStore.cs @@ -0,0 +1,121 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.InMemory; + +public abstract class InMemoryVersionedStore : IVersionedStore + where TEntity : class, IVersionedEntity, IEntitySnapshot + where TKey : notnull, IEquatable +{ + private readonly ConcurrentDictionary _store = new(); + + protected abstract TKey GetKey(TEntity entity); + protected virtual TEntity Snapshot(TEntity entity) => entity.Snapshot(); + protected virtual void BeforeAdd(TEntity entity) { } + protected virtual void BeforeSave(TEntity entity, TEntity current, long expectedVersion) { } + protected virtual void BeforeDelete(TEntity current, long expectedVersion, DeleteMode mode, DateTimeOffset now) { } + + public Task GetAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(key, out var entity)) + return Task.FromResult(null); + + return Task.FromResult(Snapshot(entity)); + } + + public Task ExistsAsync(TKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return Task.FromResult(_store.ContainsKey(key)); + } + + public virtual Task AddAsync(TEntity entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = GetKey(entity); + var snapshot = Snapshot(entity); + + BeforeAdd(snapshot); + + if (!_store.TryAdd(key, snapshot)) + throw new UAuthConflictException($"{typeof(TEntity).Name} already exists."); + + return Task.CompletedTask; + } + + public virtual Task SaveAsync(TEntity entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var key = GetKey(entity); + + if (!_store.TryGetValue(key, out var current)) + throw new UAuthNotFoundException($"{typeof(TEntity).Name} not found."); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} version conflict."); + + var next = Snapshot(entity); + next.Version = expectedVersion + 1; + + BeforeSave(next, current, expectedVersion); + + if (!_store.TryUpdate(key, next, current)) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} update conflict."); + + return Task.CompletedTask; + } + + public Task DeleteAsync(TKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_store.TryGetValue(key, out var current)) + throw new UAuthNotFoundException($"{typeof(TEntity).Name} not found."); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} version conflict."); + + BeforeDelete(current, expectedVersion, mode, now); + + if (mode == DeleteMode.Hard) + { + if (!_store.TryRemove(new KeyValuePair(key, current))) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + + return Task.CompletedTask; + } + + var next = Snapshot(current); + + if (next is not ISoftDeletable soft) + throw new UAuthConflictException($"{typeof(TEntity).Name} does not support soft delete."); + + next = soft.MarkDeleted(now); + next.Version = expectedVersion + 1; + + if (!_store.TryUpdate(key, next, current)) + throw new UAuthConcurrencyException($"{typeof(TEntity).Name} delete conflict."); + + return Task.CompletedTask; + } + + /// + /// Returns a read-only list containing the current snapshot of all entities stored in memory. + /// + protected IReadOnlyList Values() => _store.Values.Select(Snapshot).ToList().AsReadOnly(); + + /// + /// Returns an enumerable collection of all entities currently stored in memory. + /// Useful in hooks when change entity value directly. + /// + protected IEnumerable InternalValues() => _store.Values; + + protected bool TryGet(TKey key, out TEntity? entity) => _store.TryGetValue(key, out entity); +} diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/README.md b/src/persistence/CodeBeam.UltimateAuth.InMemory/README.md new file mode 100644 index 00000000..34e73d37 --- /dev/null +++ b/src/persistence/CodeBeam.UltimateAuth.InMemory/README.md @@ -0,0 +1,34 @@ +๏ปฟ# UltimateAuth InMemory Infrastructure + +Shared in-memory infrastructure for UltimateAuth. + +## Purpose + +This package provides reusable in-memory building blocks for UltimateAuth modules: + +- Thread-safe in-memory stores +- Base store implementations +- Development and testing utilities + +## โš ๏ธ Not for production + +Data is stored in memory and will be lost when the application restarts. + +## Usage + +This package is NOT intended to be installed directly. + +Instead, use module-specific packages such as: + +- CodeBeam.UltimateAuth.Users.InMemory +- CodeBeam.UltimateAuth.Credentials.InMemory +- CodeBeam.UltimateAuth.Authorization.InMemory +- CodeBeam.UltimateAuth.Authentication.InMemory +- CodeBeam.UltimateAuth.Sessions.InMemory +- CodeBeam.UltimateAuth.Tokens.InMemory + +These packages include this one automatically. + +## Advanced usage + +You may use this package directly when implementing custom in-memory providers. \ No newline at end of file diff --git a/src/persistence/CodeBeam.UltimateAuth.InMemory/logo.png b/src/persistence/CodeBeam.UltimateAuth.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/persistence/CodeBeam.UltimateAuth.InMemory/logo.png differ diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Abstractions/IAccessPolicyProvider.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Abstractions/IAccessPolicyProvider.cs new file mode 100644 index 00000000..de840571 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Abstractions/IAccessPolicyProvider.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies.Abstractions; + +public interface IAccessPolicyProvider +{ + IReadOnlyCollection GetPolicies(AccessContext context); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs new file mode 100644 index 00000000..1c13458c --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/AssemblyVisibility.cs @@ -0,0 +1,4 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Server")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj new file mode 100644 index 00000000..0078784a --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/CodeBeam.UltimateAuth.Policies.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Policies + + + Policy evaluation module for UltimateAuth. + Provides reusable authorization policy logic built on top of roles and permissions. + Can be used independently or together with the Authorization module. + This package is included transitively by CodeBeam.UltimateAuth.Server and usually does not need to be installed directly. + + + authentication;authorization;policies;permissions;security;auth-framework + logo.png + README.md + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs new file mode 100644 index 00000000..0d592554 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/CompiledAccessPolicySet.cs @@ -0,0 +1,33 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Registry; + +namespace CodeBeam.UltimateAuth.Policies.Defaults; + +public sealed class CompiledAccessPolicySet +{ + private readonly PolicyRegistration[] _registrations; + + internal CompiledAccessPolicySet(PolicyRegistration[] registrations) + { + _registrations = registrations; + } + + public IReadOnlyList Resolve(AccessContext context, IServiceProvider services) + { + var list = new List(); + + foreach (var r in _registrations) + { + if (context.Action.StartsWith(r.ActionPrefix, StringComparison.OrdinalIgnoreCase)) + { + var policy = r.Factory(services); + + if (policy.AppliesTo(context)) + list.Add(policy); + } + } + + return list; + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs new file mode 100644 index 00000000..bf91cb87 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Defaults/DefaultPolicySet.cs @@ -0,0 +1,40 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Policies.Registry; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Policies.Defaults; + +internal static class DefaultPolicySet +{ + public static void RegisterServer(AccessPolicyRegistry registry) + { + // Invariant + registry.Add("", _ => new RequireAuthenticatedPolicy()); + registry.Add("", _ => new DenyCrossTenantPolicy()); + registry.Add("", sp => new RequireActiveUserPolicy(sp.GetRequiredService())); + + // Intent-based + registry.Add("", _ => new RequireSelfPolicy()); + registry.Add("", _ => new DenyAdminSelfModificationPolicy()); + registry.Add("", _ => new RequireSystemPolicy()); + + // Permission + registry.Add("", _ => new MustHavePermissionPolicy()); + } + + public static void RegisterResource(AccessPolicyRegistry registry) + { + // Invariant + registry.Add("", _ => new RequireAuthenticatedPolicy()); + registry.Add("", _ => new DenyCrossTenantPolicy()); + + // Intent-based + registry.Add("", _ => new RequireSelfPolicy()); + registry.Add("", _ => new DenyAdminSelfModificationPolicy()); + registry.Add("", _ => new RequireSystemPolicy()); + + // Permission + registry.Add("", _ => new MustHavePermissionPolicy()); + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs new file mode 100644 index 00000000..0a0fd764 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalPolicyBuilder.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Registry; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class ConditionalPolicyBuilder : IConditionalPolicyBuilder +{ + private readonly string _prefix; + private readonly Func _condition; + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; + + public ConditionalPolicyBuilder(string prefix, Func condition, AccessPolicyRegistry registry, IServiceProvider services) + { + _prefix = prefix; + _condition = condition; + _registry = registry; + _services = services; + } + + public IPolicyScopeBuilder Then() => new ConditionalScopeBuilder(_prefix, _condition, true, _registry, _services); + public IPolicyScopeBuilder Otherwise() => new ConditionalScopeBuilder(_prefix, _condition, false, _registry, _services); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs new file mode 100644 index 00000000..4c6181ae --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/ConditionalScopeBuilder.cs @@ -0,0 +1,36 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Registry; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class ConditionalScopeBuilder : IPolicyScopeBuilder +{ + private readonly string _prefix; + private readonly Func _condition; + private readonly bool _expected; + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; + + public ConditionalScopeBuilder(string prefix, Func condition, bool expected, AccessPolicyRegistry registry, IServiceProvider services) + { + _prefix = prefix; + _condition = condition; + _expected = expected; + _registry = registry; + _services = services; + } + + private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy + { + _registry.Add(_prefix, sp => new ConditionalAccessPolicy(_condition, _expected, ActivatorUtilities.CreateInstance(sp))); + return this; + } + + public IPolicyScopeBuilder RequireSelf() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); + public IPolicyScopeBuilder RequireAuthenticated() => Add(); + public IPolicyScopeBuilder DenyCrossTenant() => Add(); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs new file mode 100644 index 00000000..64b0ba3c --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IConditionalPolicyBuilder.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Policies; + +public interface IConditionalPolicyBuilder +{ + IPolicyScopeBuilder Then(); + IPolicyScopeBuilder Otherwise(); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs new file mode 100644 index 00000000..d265c7e6 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyBuilder.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Policies; + +public interface IPolicyBuilder +{ + IPolicyScopeBuilder For(string actionPrefix); + IPolicyScopeBuilder Global(); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs new file mode 100644 index 00000000..9f400fae --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/IPolicyScopeBuilder.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Policies; + +public interface IPolicyScopeBuilder +{ + IPolicyScopeBuilder RequireAuthenticated(); + IPolicyScopeBuilder RequireSelf(); + IPolicyScopeBuilder RequirePermission(); + IPolicyScopeBuilder DenyCrossTenant(); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs new file mode 100644 index 00000000..4f3f8afc --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyBuilder.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Policies.Registry; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class PolicyBuilder : IPolicyBuilder +{ + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; + + public PolicyBuilder(AccessPolicyRegistry registry, IServiceProvider services) + { + _registry = registry; + _services = services; + } + + public IPolicyScopeBuilder For(string actionPrefix) => new PolicyScopeBuilder(actionPrefix, _registry, _services); + + public IPolicyScopeBuilder Global() => new PolicyScopeBuilder(string.Empty, _registry, _services); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs new file mode 100644 index 00000000..134ba717 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Fluent/PolicyScopeBuilder.cs @@ -0,0 +1,37 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Registry; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class PolicyScopeBuilder : IPolicyScopeBuilder +{ + private readonly string _prefix; + private readonly AccessPolicyRegistry _registry; + private readonly IServiceProvider _services; + + public PolicyScopeBuilder(string prefix, AccessPolicyRegistry registry, IServiceProvider services) + { + _prefix = prefix; + _registry = registry; + _services = services; + } + + public IPolicyScopeBuilder RequireAuthenticated() => Add(); + public IPolicyScopeBuilder RequireSelf() => Add(); + public IPolicyScopeBuilder RequirePermission() => Add(); + public IPolicyScopeBuilder DenyCrossTenant() => Add(); + + private IPolicyScopeBuilder Add() where TPolicy : IAccessPolicy + { + _registry.Add(_prefix, sp => ActivatorUtilities.CreateInstance(sp)); + return this; + } + + public IConditionalPolicyBuilder When(Func predicate) + { + return new ConditionalPolicyBuilder(_prefix, predicate, _registry, _services); + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs new file mode 100644 index 00000000..eb7e33bc --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/ConditionalAccessPolicy.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class ConditionalAccessPolicy : IAccessPolicy +{ + private readonly Func _condition; + private readonly bool _expected; + private readonly IAccessPolicy _inner; + + public ConditionalAccessPolicy(Func condition, bool expected, IAccessPolicy inner) + { + _condition = condition; + _expected = expected; + _inner = inner; + } + + public bool AppliesTo(AccessContext context) => _condition(context) == _expected; + + public AccessDecision Decide(AccessContext context) => _inner.Decide(context); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs new file mode 100644 index 00000000..d2f682ee --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyAdminSelfModificationPolicy.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class DenyAdminSelfModificationPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (!context.IsAuthenticated) + return AccessDecision.Deny("unauthenticated"); + + if (context.ActorUserKey == context.TargetUserKey) + return AccessDecision.Deny("admin_cannot_modify_own_account"); + + return AccessDecision.Allow(); + } + + public bool AppliesTo(AccessContext context) + { + if (!context.Action.EndsWith(".admin")) + return false; + + if (context.TargetUserKey is null) + return false; + + // READ actions allowed + if (context.Action.Contains(".get") || + context.Action.Contains(".read") || + context.Action.Contains(".query")) + return false; + + return true; + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyCrossTenantPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyCrossTenantPolicy.cs new file mode 100644 index 00000000..b3a987b8 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/DenyCrossTenantPolicy.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class DenyCrossTenantPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + return context.IsCrossTenant + ? AccessDecision.Deny("cross_tenant_access_denied") + : AccessDecision.Allow(); + } + + public bool AppliesTo(AccessContext context) => true; +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs new file mode 100644 index 00000000..0e5814ec --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/MustHavePermissionPolicy.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Authorization.Policies; + +public sealed class MustHavePermissionPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (context.Attributes.TryGetValue(UAuthConstants.Access.Permissions, out var value) && value is CompiledPermissionSet permissions) + { + if (permissions.IsAllowed(context.Action)) + return AccessDecision.Allow(); + } + + return AccessDecision.Deny("missing_permission"); + } + + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".admin", StringComparison.OrdinalIgnoreCase); + } +} \ No newline at end of file diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs new file mode 100644 index 00000000..ba1cbd35 --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireActiveUserPolicy.cs @@ -0,0 +1,46 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireActiveUserPolicy : IAccessPolicy +{ + private readonly IUserRuntimeStateProvider _runtime; + + public RequireActiveUserPolicy(IUserRuntimeStateProvider runtime) + { + _runtime = runtime; + } + + public AccessDecision Decide(AccessContext context) + { + if (context.ActorUserKey is null) + return AccessDecision.Deny("missing_actor"); + + var state = _runtime.GetAsync(context.ActorTenant, context.ActorUserKey!.Value).GetAwaiter().GetResult(); + + if (state == null || !state.Exists || state.IsDeleted) + return AccessDecision.Deny("user_not_found"); + + return state.IsActive + ? AccessDecision.Allow() + : AccessDecision.Deny("user_not_active"); + } + + public bool AppliesTo(AccessContext context) + { + if (!context.IsAuthenticated || context.IsSystemActor) + return false; + + if (context.Action.EndsWith(".anonymous")) + return false; + + return !AllowedForInactive.Contains(context.Action); + } + + private static readonly string[] AllowedForInactive = + { + UAuthActions.Users.ChangeStatusSelf, + }; +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs new file mode 100644 index 00000000..fb26bb6a --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireAuthenticatedPolicy.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireAuthenticatedPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + return context.IsAuthenticated + ? AccessDecision.Allow() + : AccessDecision.Deny("unauthenticated"); + } + + public bool AppliesTo(AccessContext context) + { + return !context.Action.EndsWith(".anonymous"); + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs new file mode 100644 index 00000000..595fde6b --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSelfPolicy.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireSelfPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + { + if (!context.IsAuthenticated) + return AccessDecision.Deny("unauthenticated"); + + return context.IsSelfAction + ? AccessDecision.Allow() + : AccessDecision.Deny("not_self"); + } + + public bool AppliesTo(AccessContext context) + { + return context.Action.EndsWith(".self"); + } +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs new file mode 100644 index 00000000..8f2740ab --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Policies/RequireSystemPolicy.cs @@ -0,0 +1,14 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies; + +internal sealed class RequireSystemPolicy : IAccessPolicy +{ + public AccessDecision Decide(AccessContext context) + => context.IsSystemActor + ? AccessDecision.Allow() + : AccessDecision.Deny("system_actor_required"); + + public bool AppliesTo(AccessContext context) => context.Action.EndsWith(".system", StringComparison.Ordinal); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/README.md b/src/policies/CodeBeam.UltimateAuth.Policies/README.md new file mode 100644 index 00000000..2de5de6c --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/README.md @@ -0,0 +1,23 @@ +๏ปฟ# UltimateAuth Policies + +Policy evaluation module for UltimateAuth. + +## Purpose + +This package provides: + +- Authorization policy evaluation logic +- Permission-based decision making +- Reusable policy helpers + +## Does NOT include + +- Persistence +- User or credential management +- ASP.NET Core integration + +โš ๏ธ This package is typically installed transitively via: + +- CodeBeam.UltimateAuth.Server + +In most cases, you do not need to install it directly unless you are building custom policy logic. \ No newline at end of file diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Registry/AccessPolicyRegistry.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Registry/AccessPolicyRegistry.cs new file mode 100644 index 00000000..308b1c8f --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Registry/AccessPolicyRegistry.cs @@ -0,0 +1,43 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Policies.Defaults; + +namespace CodeBeam.UltimateAuth.Policies.Registry; + +public sealed class AccessPolicyRegistry +{ + private readonly List _policies = new(); + private bool _built; + + internal void Add(string actionPrefix, Func factory) + { + if (_built) + { + throw new InvalidOperationException("AccessPolicyRegistry is already built. Policies cannot be modified after Build()."); + } + + _policies.Add(new PolicyRegistration(actionPrefix, factory)); + } + + public IReadOnlyList Resolve(AccessContext context, IServiceProvider services) + { + return _policies + .Where(p => context.Action.StartsWith(p.ActionPrefix, StringComparison.OrdinalIgnoreCase)) + .Select(p => p.Factory(services)) + .ToList(); + } + + public CompiledAccessPolicySet Build() + { + if (_built) + throw new InvalidOperationException("AccessPolicyRegistry.Build() can only be called once."); + + _built = true; + + return new CompiledAccessPolicySet(_policies.OrderBy(r => r.ActionPrefix.Length).ToArray()); + } +} + +internal sealed record PolicyRegistration( + string ActionPrefix, + Func Factory); diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/Registry/PolicyRule.cs b/src/policies/CodeBeam.UltimateAuth.Policies/Registry/PolicyRule.cs new file mode 100644 index 00000000..e5b6b5ed --- /dev/null +++ b/src/policies/CodeBeam.UltimateAuth.Policies/Registry/PolicyRule.cs @@ -0,0 +1,18 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Policies.Registry; + +public sealed class PolicyRule +{ + public string ActionPrefix { get; } + public Func Factory { get; } + + public PolicyRule(string actionPrefix, Func factory) + { + ActionPrefix = actionPrefix; + Factory = factory; + } + + public bool Matches(string action) => action.StartsWith(ActionPrefix, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/policies/CodeBeam.UltimateAuth.Policies/logo.png b/src/policies/CodeBeam.UltimateAuth.Policies/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/policies/CodeBeam.UltimateAuth.Policies/logo.png differ diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs new file mode 100644 index 00000000..4f73b643 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2Options.cs @@ -0,0 +1,12 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Security.Argon2; + +public sealed class Argon2Options +{ + // OWASP recommended baseline + public int MemorySizeKb { get; init; } = 64 * 1024; // 64 MB + public int Iterations { get; init; } = 3; + public int Parallelism { get; init; } = Environment.ProcessorCount; + + public int SaltSize { get; init; } = 16; + public int HashSize { get; init; } = 32; +} diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs new file mode 100644 index 00000000..2ec21417 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/Argon2PasswordHasher.cs @@ -0,0 +1,70 @@ +๏ปฟusing System.Security.Cryptography; +using System.Text; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Errors; +using Konscious.Security.Cryptography; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Security.Argon2; + +internal sealed class Argon2PasswordHasher : IUAuthPasswordHasher +{ + private readonly Argon2Options _options; + + public Argon2PasswordHasher(IOptions options) + { + _options = options.Value; + } + + public string Hash(string password) + { + if (string.IsNullOrEmpty(password)) + throw new UAuthValidationException("Password cannot be null or empty."); + + var salt = RandomNumberGenerator.GetBytes(_options.SaltSize); + + var argon2 = CreateArgon2(password, salt); + + var hash = argon2.GetBytes(_options.HashSize); + + // format: + // {salt}.{hash} + return $"{Convert.ToBase64String(salt)}.{Convert.ToBase64String(hash)}"; + } + + public bool Verify(string hash, string secret) + { + if (string.IsNullOrWhiteSpace(secret) || string.IsNullOrWhiteSpace(hash)) + return false; + + var parts = hash.Split('.'); + if (parts.Length != 2) + return false; + + try + { + var salt = Convert.FromBase64String(parts[0]); + var expectedHash = Convert.FromBase64String(parts[1]); + + var argon2 = CreateArgon2(secret, salt); + var actualHash = argon2.GetBytes(expectedHash.Length); + + return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash); + } + catch + { + return false; + } + } + + private Argon2id CreateArgon2(string password, byte[] salt) + { + return new Argon2id(Encoding.UTF8.GetBytes(password)) + { + Salt = salt, + DegreeOfParallelism = _options.Parallelism, + Iterations = _options.Iterations, + MemorySize = _options.MemorySizeKb + }; + } +} diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/AssemblyVisibility.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/AssemblyVisibility.cs new file mode 100644 index 00000000..7ab12e68 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/AssemblyVisibility.cs @@ -0,0 +1,4 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Reference.Bundle")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj new file mode 100644 index 00000000..3c58caae --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/CodeBeam.UltimateAuth.Security.Argon2.csproj @@ -0,0 +1,33 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Security.Argon2 + + + Argon2 password hashing provider for UltimateAuth. + Provides a secure implementation of IUAuthPasswordHasher using Argon2, designed for modern authentication systems with strong resistance against brute-force and GPU attacks. + + + authentication;security;argon2;password-hashing;cryptography;identity;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + + + diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/README.md b/src/security/CodeBeam.UltimateAuth.Security.Argon2/README.md new file mode 100644 index 00000000..ea63895d --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/README.md @@ -0,0 +1,28 @@ +๏ปฟ# UltimateAuth Argon2 Security Provider + +Argon2 password hashing implementation for UltimateAuth. + +This package provides a secure implementation of: + +- `IUAuthPasswordHasher` + +using the Argon2 algorithm. + +--- + +## ๐Ÿ” Why Argon2? + +Argon2 is a modern password hashing algorithm designed to resist: + +- GPU attacks +- Parallel brute-force attacks +- Memory trade-off attacks + +It is recommended for secure password storage in modern applications. + +--- + +โš ๏ธ Notes + +- UltimateAuth doesn't have default password hasher. You need one of the security packages. +- You should use only one IUAuthPasswordHasher implementation. diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..10738291 --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Security.Argon2; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthArgon2(this IServiceCollection services, Action? configure = null) + { + if (configure != null) + { + services.Configure(configure); + } + else + { + services.Configure(_ => { }); + } + + services.AddSingleton(); + + return services; + } +} diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs new file mode 100644 index 00000000..cbce83da --- /dev/null +++ b/src/security/CodeBeam.UltimateAuth.Security.Argon2/UltimateAuthServerBuilderArgon2Extensions.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Security.Argon2; + +namespace CodeBeam.UltimateAuth.Server.Composition.Extensions; + +public static class UltimateAuthServerBuilderArgon2Extensions +{ + public static UltimateAuthServerBuilder UseArgon2(this UltimateAuthServerBuilder builder, Action? configure = null) + { + builder.Services.AddUltimateAuthArgon2(configure); + return builder; + } +} diff --git a/src/security/CodeBeam.UltimateAuth.Security.Argon2/logo.png b/src/security/CodeBeam.UltimateAuth.Security.Argon2/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/security/CodeBeam.UltimateAuth.Security.Argon2/logo.png differ diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AssemblyVisibility.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj new file mode 100644 index 00000000..2f642a6e --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore + + + Entity Framework Core session persistence for UltimateAuth. + Provides durable storage for session lifecycle and validation. + + + authentication;sessions;efcore;database;auth-framework + logo.png + README.md + + + + + + + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs new file mode 100644 index 00000000..93fcd842 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionDbContext.cs @@ -0,0 +1,20 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public sealed class UAuthSessionDbContext : DbContext +{ + public DbSet Roots => Set(); + public DbSet Chains => Set(); + public DbSet Sessions => Set(); + + + public UAuthSessionDbContext(DbContextOptions options) : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UAuthSessionsModelBuilder.Configure(modelBuilder); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs new file mode 100644 index 00000000..e6d98900 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Data/UAuthSessionsModelBuilder.cs @@ -0,0 +1,170 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public static class UAuthSessionsModelBuilder +{ + public static void Configure(ModelBuilder b) + { + ConfigureRoots(b); + ConfigureChains(b); + ConfigureSessions(b); + } + + private static void ConfigureRoots(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_SessionRoots"); + e.HasKey(x => x.Id); + + e.Property(x => x.Version).IsConcurrencyToken(); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.RevokedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.RootId) + .HasConversion(v => v.Value, v => SessionRootId.From(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.RootId }).IsUnique(); + }); + } + + private static void ConfigureChains(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_SessionChains"); + e.HasKey(x => x.Id); + + e.Property(x => x.Version).IsConcurrencyToken(); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.LastSeenAt).HasUtcDateTimeOffsetConverter(); + e.Property(x => x.RevokedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.AbsoluteExpiresAt).HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.ChainId) + .HasConversion(v => v.Value, v => SessionChainId.From(v)) + .IsRequired(); + + e.Property(x => x.DeviceId) + .HasConversion(v => v.Value, v => DeviceId.Create(v)) + .HasMaxLength(64) + .IsRequired(); + + e.Property(x => x.Device) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.ActiveSessionId) + .HasConversion(new NullableAuthSessionIdConverter()); + + e.Property(x => x.ClaimsSnapshot) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.SecurityVersionAtCreation) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.ChainId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.DeviceId }); + e.HasIndex(x => new { x.Tenant, x.RootId }); + + e.HasOne() + .WithMany() + .HasForeignKey(x => new { x.Tenant, x.RootId }) + .HasPrincipalKey(x => new { x.Tenant, x.RootId }) + .OnDelete(DeleteBehavior.Restrict); + }); + } + + private static void ConfigureSessions(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_Sessions"); + e.HasKey(x => x.Id); + + e.Property(x => x.Version).IsConcurrencyToken(); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.ExpiresAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.RevokedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.SessionId) + .HasConversion(new AuthSessionIdConverter()) + .IsRequired(); + + e.Property(x => x.ChainId) + .HasConversion(v => v.Value, v => SessionChainId.From(v)) + .IsRequired(); + + e.Property(x => x.Device) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.Claims) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.Property(x => x.Metadata) + .HasConversion(new JsonValueConverter()) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.SessionId }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.ChainId }); + e.HasIndex(x => new { x.Tenant, x.ChainId, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + e.HasIndex(x => new { x.Tenant, x.RevokedAt }); + + e.HasOne() + .WithMany() + .HasForeignKey(x => new { x.Tenant, x.ChainId }) + .HasPrincipalKey(x => new { x.Tenant, x.ChainId }) + .OnDelete(DeleteBehavior.Restrict); + }); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..2dc070af --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthSessionsEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext + { + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + + return services; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs new file mode 100644 index 00000000..5fd8819f --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionChainProjectionMapper.cs @@ -0,0 +1,68 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal static class SessionChainProjectionMapper +{ + public static UAuthSessionChain ToDomain(this SessionChainProjection p) + { + return UAuthSessionChain.FromProjection( + p.ChainId, + p.RootId, + p.Tenant, + p.UserKey, + p.CreatedAt, + p.LastSeenAt, + p.AbsoluteExpiresAt, + p.Device, + p.ClaimsSnapshot, + p.ActiveSessionId, + p.RotationCount, + p.TouchCount, + p.SecurityVersionAtCreation, + p.RevokedAt, + p.Version + ); + } + + public static SessionChainProjection ToProjection(this UAuthSessionChain chain) + { + if (chain.Device.DeviceId is not DeviceId deviceId) + throw new ArgumentException("Device id required."); + + return new SessionChainProjection + { + ChainId = chain.ChainId, + RootId = chain.RootId, + Tenant = chain.Tenant, + UserKey = chain.UserKey, + CreatedAt = chain.CreatedAt, + LastSeenAt = chain.LastSeenAt, + AbsoluteExpiresAt = chain.AbsoluteExpiresAt, + DeviceId = deviceId, + Device = chain.Device, + ClaimsSnapshot = chain.ClaimsSnapshot, + ActiveSessionId = chain.ActiveSessionId, + RotationCount = chain.RotationCount, + TouchCount = chain.TouchCount, + SecurityVersionAtCreation = chain.SecurityVersionAtCreation, + RevokedAt = chain.RevokedAt, + Version = chain.Version + }; + } + + public static void UpdateProjection(this UAuthSessionChain source, SessionChainProjection target) + { + DeviceId.TryCreate(source.Device.DeviceId?.Value, out var deviceId); + + target.ActiveSessionId = source.ActiveSessionId; + target.RevokedAt = source.RevokedAt; + target.DeviceId = deviceId; + target.Device = source.Device; + target.ClaimsSnapshot = source.ClaimsSnapshot; + target.SecurityVersionAtCreation = source.SecurityVersionAtCreation; + target.LastSeenAt = source.LastSeenAt; + target.AbsoluteExpiresAt = source.AbsoluteExpiresAt; + // Version store-owned + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs new file mode 100644 index 00000000..be5f5413 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionProjectionMapper.cs @@ -0,0 +1,56 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal static class SessionProjectionMapper +{ + public static UAuthSession ToDomain(this SessionProjection p) + { + return UAuthSession.FromProjection( + p.SessionId, + p.Tenant, + p.UserKey, + p.ChainId, + p.CreatedAt, + p.ExpiresAt, + p.RevokedAt, + p.SecurityVersionAtCreation, + p.Device, + p.Claims, + p.Metadata, + p.Version + ); + } + + public static SessionProjection ToProjection(this UAuthSession s) + { + return new SessionProjection + { + SessionId = s.SessionId, + Tenant = s.Tenant, + UserKey = s.UserKey, + ChainId = s.ChainId, + + CreatedAt = s.CreatedAt, + ExpiresAt = s.ExpiresAt, + RevokedAt = s.RevokedAt, + + SecurityVersionAtCreation = s.SecurityVersionAtCreation, + Device = s.Device, + Claims = s.Claims, + Metadata = s.Metadata, + Version = s.Version + }; + } + + public static void UpdateProjection(this UAuthSession source, SessionProjection target) + { + target.ExpiresAt = source.ExpiresAt; + target.RevokedAt = source.RevokedAt; + target.SecurityVersionAtCreation = source.SecurityVersionAtCreation; + target.Device = source.Device; + target.Claims = source.Claims; + target.Metadata = source.Metadata; + // Version store-owned + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs new file mode 100644 index 00000000..70a2b038 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Mappers/SessionRootProjectionMapper.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal static class SessionRootProjectionMapper +{ + public static UAuthSessionRoot ToDomain(this SessionRootProjection root) + { + return UAuthSessionRoot.FromProjection( + root.RootId, + root.Tenant, + root.UserKey, + root.CreatedAt, + root.UpdatedAt, + root.RevokedAt, + root.SecurityVersion, + root.Version + ); + } + + public static SessionRootProjection ToProjection(this UAuthSessionRoot root) + { + return new SessionRootProjection + { + RootId = root.RootId, + Tenant = root.Tenant, + UserKey = root.UserKey, + + CreatedAt = root.CreatedAt, + UpdatedAt = root.UpdatedAt, + RevokedAt = root.RevokedAt, + + SecurityVersion = root.SecurityVersion, + Version = root.Version + }; + } + + public static void UpdateProjection(this UAuthSessionRoot source, SessionRootProjection target) + { + target.UpdatedAt = source.UpdatedAt; + target.RevokedAt = source.RevokedAt; + target.SecurityVersion = source.SecurityVersion; + // Version store-owned + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs new file mode 100644 index 00000000..c9f417a9 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionChainProjection.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public sealed class SessionChainProjection +{ + public long Id { get; set; } + + public SessionChainId ChainId { get; set; } = default!; + public SessionRootId RootId { get; set; } + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset LastSeenAt { get; set; } + public DateTimeOffset? AbsoluteExpiresAt { get; set; } + public DeviceId DeviceId { get; set; } + public DeviceContext Device { get; set; } = DeviceContext.Anonymous(); + public ClaimsSnapshot ClaimsSnapshot { get; set; } = ClaimsSnapshot.Empty; + public AuthSessionId? ActiveSessionId { get; set; } + public int RotationCount { get; set; } + public int TouchCount { get; set; } + public long SecurityVersionAtCreation { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } + public long Version { get; set; } + + public bool IsRevoked => RevokedAt is not null; + public SessionChainState State => IsRevoked ? SessionChainState.Revoked : ActiveSessionId is null ? SessionChainState.Passive : SessionChainState.Active; +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs new file mode 100644 index 00000000..7860d578 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionProjection.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public sealed class SessionProjection +{ + public long Id { get; set; } // EF internal PK + + public AuthSessionId SessionId { get; set; } = default!; + public SessionChainId ChainId { get; set; } = default!; + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public long SecurityVersionAtCreation { get; set; } + + public DeviceContext Device { get; set; } = DeviceContext.Anonymous(); + public ClaimsSnapshot Claims { get; set; } = ClaimsSnapshot.Empty; + public SessionMetadata Metadata { get; set; } = SessionMetadata.Empty; + + public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs new file mode 100644 index 00000000..c6ae69d9 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Projections/SessionRootProjection.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +public sealed class SessionRootProjection +{ + public long Id { get; set; } + public SessionRootId RootId { get; set; } + public TenantKey Tenant { get; set; } + public UserKey UserKey { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? RevokedAt { get; set; } + + public long SecurityVersion { get; set; } + public long Version { get; set; } + + public bool IsRevoked => RevokedAt != null; +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/README.md b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/README.md new file mode 100644 index 00000000..5b67fdac --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/README.md @@ -0,0 +1,32 @@ +๏ปฟ# UltimateAuth Sessions EntityFrameworkCore + +Entity Framework Core persistence for UltimateAuth sessions. + +## Purpose + +Provides durable session storage for: + +- Active sessions +- Session lifecycle management +- Expiration and validation + +## Features + +- Persistent session storage +- Expiration tracking +- Scalable architecture support + +## Notes + +- Requires EF Core setup +- Migrations handled by application + +## When to use + +- Production environments +- Distributed systems + +## Alternatives + +- CodeBeam.UltimateAuth.Sessions.InMemory +- Custom packages \ No newline at end of file diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs new file mode 100644 index 00000000..ca86ff54 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs @@ -0,0 +1,628 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; +using System.Data; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionStore : ISessionStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreSessionStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSetSession => _db.Set(); + private DbSet DbSetChain => _db.Set(); + private DbSet DbSetRoot => _db.Set(); + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + await tx.RollbackAsync(ct); + throw new UAuthConcurrencyException("concurrency_conflict"); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.RepeatableRead, ct); + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch (DbUpdateConcurrencyException) + { + await tx.RollbackAsync(ct); + throw new UAuthConcurrencyException("concurrency_conflict"); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + }); + } + + public async Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var local = DbSetSession.Local.FirstOrDefault(x => x.Tenant == _tenant && x.SessionId == sessionId); + + if (local != null) + return local.ToDomain(); + + var projection = await DbSetSession + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId); + + return projection?.ToDomain(); + } + + public async Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = DbSetSession.Local.FirstOrDefault(x => x.Tenant == _tenant && x.SessionId == session.SessionId); + + if (projection == null) + { + projection = await DbSetSession + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.SessionId == session.SessionId, + ct); + } + + if (projection is null) + throw new UAuthNotFoundException("session_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("session_concurrency_conflict"); + + session.UpdateProjection(projection); + projection.Version++; + } + + public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = session.ToProjection(); + + if (session.Version != 0) + throw new InvalidOperationException("New session must have version 0."); + + DbSetSession.Add(projection); + + return Task.CompletedTask; + } + + public async Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSetSession.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); + + if (projection is null || projection.RevokedAt is not null) + return false; + + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; + + return true; + } + + public async Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await DbSetChain + .Where(x => x.Tenant == _tenant && x.UserKey == user) + .ToListAsync(ct); + + var chainIds = chains.Select(x => x.ChainId).ToList(); + + var sessions = await DbSetSession + .Where(x => x.Tenant == _tenant && chainIds.Contains(x.ChainId)) + .ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + if (sessionProjection.RevokedAt is not null) + continue; + + var domain = sessionProjection.ToDomain().Revoke(at); + domain.UpdateProjection(sessionProjection); + sessionProjection.Version++; + } + + foreach (var chainProjection in chains) + { + if (chainProjection.ActiveSessionId is null) + continue; + + var domain = chainProjection.ToDomain().DetachSession(at); + + domain.UpdateProjection(chainProjection); + chainProjection.Version++; + } + } + + public async Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chains = await DbSetChain + .Where(x => x.Tenant == _tenant && x.UserKey == user && x.ChainId != keepChain) + .ToListAsync(ct); + + var chainIds = chains.Select(x => x.ChainId).ToList(); + + var sessions = await DbSetSession + .Where(x => x.Tenant == _tenant && chainIds.Contains(x.ChainId)) + .ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + if (sessionProjection.RevokedAt is not null) + continue; + + var domain = sessionProjection.ToDomain().Revoke(at); + domain.UpdateProjection(sessionProjection); + sessionProjection.Version++; + } + + foreach (var chainProjection in chains) + { + if (chainProjection.ActiveSessionId is null) + continue; + + var domain = chainProjection.ToDomain().DetachSession(at); + + domain.UpdateProjection(chainProjection); + chainProjection.Version++; + } + } + + public async Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var local = DbSetChain.Local.FirstOrDefault(x => x.Tenant == _tenant && x.ChainId == chainId); + + if (local is not null) + return local.ToDomain(); + + var projection = await DbSetChain + .AsNoTracking() + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + + return projection?.ToDomain(); + } + + public async Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var local = DbSetChain.Local + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null && + x.DeviceId == deviceId) + .FirstOrDefault(); + + if (local != null) + return local.ToDomain(); + + var projection = await DbSetChain + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null && + x.DeviceId == deviceId) + .FirstOrDefaultAsync(ct); + + return projection?.ToDomain(); + } + + public async Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = DbSetChain.Local.FirstOrDefault(x => x.Tenant == _tenant && x.ChainId == chain.ChainId); + + if (projection is null) + { + projection = await DbSetChain + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chain.ChainId, ct); + } + + if (projection is null) + throw new UAuthNotFoundException("chain_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("chain_concurrency_conflict"); + + chain.UpdateProjection(projection); + projection.Version++; + } + + public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (chain.Version != 0) + throw new InvalidOperationException("New chain must have version 0."); + + var projection = chain.ToProjection(); + + DbSetChain.Add(projection); + _db.Entry(projection).State = EntityState.Added; + + return Task.CompletedTask; + } + + public async Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSetChain + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + + if (projection is null || projection.RevokedAt is not null) + return; + + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; + } + + public async Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await DbSetChain + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + + if (chainProjection is null || chainProjection.RevokedAt is not null) + return; + + var sessions = await DbSetSession + .Where(x => x.Tenant == _tenant && x.ChainId == chainId) + .ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + if (sessionProjection.RevokedAt is not null) + continue; + + var domain = sessionProjection.ToDomain().Revoke(at); + + domain.UpdateProjection(sessionProjection); + sessionProjection.Version++; + } + + if (chainProjection.ActiveSessionId is not null) + { + var domain = chainProjection.ToDomain().DetachSession(at); + domain.UpdateProjection(chainProjection); + chainProjection.Version++; + } + } + + public async Task RevokeOtherChainsAsync(UserKey userKey, SessionChainId currentChainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSetChain + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.ChainId != currentChainId && + x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; + } + } + + public async Task RevokeAllChainsAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSetChain + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var projection in projections) + { + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; + } + } + + public async Task GetActiveSessionIdAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await DbSetChain + .AsNoTracking() + .Where(x => x.Tenant == _tenant && x.ChainId == chainId) + .Select(x => x.ActiveSessionId) + .SingleOrDefaultAsync(); + } + + public async Task SetActiveSessionIdAsync(SessionChainId chainId, AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = DbSetChain.Local.FirstOrDefault(x => x.Tenant == _tenant && x.ChainId == chainId); + + if (projection is null) + { + projection = await DbSetChain.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + } + + if (projection is null) + throw new UAuthNotFoundException("chain_not_found"); + + projection.ActiveSessionId = sessionId; + projection.Version++; + } + + public async Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await DbSetRoot.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); + return rootProjection?.ToDomain(); + } + + public async Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSetRoot + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == root.UserKey, + ct); + + if (projection is null) + throw new UAuthNotFoundException("root_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("root_concurrency_conflict"); + + root.UpdateProjection(projection); + projection.Version++; + } + + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + var projection = root.ToProjection(); + + DbSetRoot.Add(projection); + + return Task.CompletedTask; + } + + public async Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSetRoot + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); + + if (projection is null || projection.RevokedAt is not null) + return; + + var domain = projection.ToDomain().Revoke(at); + domain.UpdateProjection(projection); + projection.Version++; + } + + public async Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await DbSetSession + .AsNoTracking() + .Where(x => x.Tenant == _tenant && x.SessionId == sessionId) + .Select(x => (SessionChainId?)x.ChainId) + .SingleOrDefaultAsync(); + } + + public async Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootsQuery = DbSetRoot.AsNoTracking().Where(x => x.Tenant == _tenant && x.UserKey == userKey); + + if (!includeHistoricalRoots) + { + rootsQuery = rootsQuery.Where(x => x.RevokedAt == null); + } + + var rootIds = await rootsQuery.Select(r => r.RootId).ToListAsync(); + + if (rootIds.Count == 0) + return Array.Empty(); + + var projections = await DbSetChain.AsNoTracking().Where(x => x.Tenant == _tenant && rootIds.Contains(x.RootId)).ToListAsync(); + return projections.Select(c => c.ToDomain()).ToList(); + } + + public async Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSetChain + .AsNoTracking() + .Where(x => x.Tenant == _tenant && x.RootId == rootId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSetSession + .AsNoTracking() + .Where(x => x.Tenant == _tenant && x.ChainId == chainId) + .ToListAsync(); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSetRoot.AsNoTracking().SingleOrDefaultAsync(x => x.Tenant == _tenant && x.RootId == rootId, ct); + return projection?.ToDomain(); + } + + public async Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSetSession.SingleOrDefaultAsync(x => x.Tenant == _tenant && x.SessionId == sessionId, ct); + + if (projection is null) + return; + + DbSetSession.Remove(projection); + } + + public async Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chainProjection = await DbSetChain + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.ChainId == chainId, ct); + + if (chainProjection is null) + return; + + var sessionProjections = await DbSetSession + .Where(x => x.Tenant == _tenant && x.ChainId == chainId && x.RevokedAt == null) + .ToListAsync(ct); + + foreach (var sessionProjection in sessionProjections) + { + var revoked = sessionProjection.ToDomain().Revoke(at); + revoked.UpdateProjection(sessionProjection); + sessionProjection.Version++; + } + + if (chainProjection.RevokedAt is null) + { + var revokedChain = chainProjection.ToDomain().Revoke(at); + revokedChain.UpdateProjection(chainProjection); + chainProjection.Version++; + } + } + + public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var rootProjection = await DbSetRoot + .SingleOrDefaultAsync(x => x.Tenant == _tenant && x.UserKey == userKey, ct); + + if (rootProjection is null) + return; + + var chainProjections = await DbSetChain + .Where(x => x.Tenant == _tenant && x.UserKey == userKey) + .ToListAsync(ct); + + foreach (var chainProjection in chainProjections) + { + var sessions = await DbSetSession + .Where(x => x.Tenant == _tenant && x.ChainId == chainProjection.ChainId) + .ToListAsync(ct); + + foreach (var sessionProjection in sessions) + { + if (sessionProjection.RevokedAt is not null) + continue; + + var sessionDomain = sessionProjection.ToDomain().Revoke(at); + + sessionDomain.UpdateProjection(sessionProjection); + sessionProjection.Version++; + } + + if (chainProjection.RevokedAt is null) + { + var chainDomain = chainProjection.ToDomain().Revoke(at); + + chainDomain.UpdateProjection(chainProjection); + chainProjection.Version++; + } + } + + if (rootProjection.RevokedAt is null) + { + var rootDomain = rootProjection.ToDomain().Revoke(at); + + rootDomain.UpdateProjection(rootProjection); + rootProjection.Version++; + } + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs new file mode 100644 index 00000000..363e3738 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; + +internal sealed class EfCoreSessionStoreFactory : ISessionStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreSessionStoreFactory(TDbContext db) + { + _db = db; + } + + public ISessionStore Create(TenantKey tenant) + { + return new EfCoreSessionStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/logo.png b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/logo.png differ diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj new file mode 100644 index 00000000..52f19a0c --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/CodeBeam.UltimateAuth.Sessions.InMemory.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Sessions.InMemory + + + In-memory session persistence for UltimateAuth. + Provides lightweight storage for session state and lifecycle. + Suitable for development and testing scenarios only. + + + authentication;sessions;inmemory;auth-framework + logo.png + README.md + + + + + + + + + + + + + + diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs new file mode 100644 index 00000000..c21a91d6 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs @@ -0,0 +1,515 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +internal sealed class InMemorySessionStore : ISessionStore +{ + private readonly SemaphoreSlim _tx = new(1, 1); + private readonly object _lock = new(); + private readonly TenantKey _tenant; + + public InMemorySessionStore(TenantKey tenant) + { + _tenant = tenant; + } + + private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _chains = new(); + private readonly ConcurrentDictionary<(TenantKey, UserKey), UAuthSessionRoot> _roots = new(); + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + try + { + return await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task GetSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_sessions.TryGetValue(sessionId, out var s) ? s : null); + } + + public Task SaveSessionAsync(UAuthSession session, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_sessions.TryGetValue(session.SessionId, out var current)) + throw new UAuthNotFoundException("session_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("session_concurrency_conflict"); + + _sessions[session.SessionId] = session; + return Task.CompletedTask; + } + + public Task CreateSessionAsync(UAuthSession session, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_sessions.ContainsKey(session.SessionId)) + throw new UAuthConcurrencyException("session_already_exists"); + + if (session.Version != 0) + throw new InvalidOperationException("New session must have version 0."); + + _sessions[session.SessionId] = session; + } + + return Task.CompletedTask; + } + + public Task RevokeSessionAsync(AuthSessionId sessionId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(false); + + if (session.IsRevoked) + return Task.FromResult(false); + + var revoked = session.Revoke(at); + _sessions[sessionId] = revoked; + + if (_chains.TryGetValue(session.ChainId, out var chain)) + { + if (chain.ActiveSessionId == sessionId) + { + var updatedChain = chain.DetachSession(at); + _chains[chain.ChainId] = updatedChain; + } + } + + return Task.FromResult(true); + } + + public Task RevokeAllSessionsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + foreach (var (id, chain) in _chains) + { + if (chain.UserKey != user) + continue; + + var sessions = _sessions.Values.Where(s => s.ChainId == id).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revoked = session.Revoke(at); + _sessions[session.SessionId] = revoked; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[id] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task RevokeOtherSessionsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + foreach (var (id, chain) in _chains) + { + if (chain.UserKey != user) + continue; + + if (id == keepChain) + continue; + + var sessions = _sessions.Values.Where(s => s.ChainId == id).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revoked = session.Revoke(at); + _sessions[session.SessionId] = revoked; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[id] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task GetChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_chains.TryGetValue(chainId, out var c) ? c : null); + } + + public Task SaveChainAsync(UAuthSessionChain chain, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_chains.TryGetValue(chain.ChainId, out var current)) + throw new UAuthNotFoundException("chain_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("chain_concurrency_conflict"); + + _chains[chain.ChainId] = chain; + return Task.CompletedTask; + } + + public Task CreateChainAsync(UAuthSessionChain chain, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_chains.ContainsKey(chain.ChainId)) + throw new UAuthConcurrencyException("chain_already_exists"); + + if (chain.Version != 0) + throw new InvalidOperationException("New chain must have version 0."); + + _chains[chain.ChainId] = chain; + } + + return Task.CompletedTask; + } + + public Task RevokeChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_chains.TryGetValue(chainId, out var chain)) + { + _chains[chainId] = chain.Revoke(at); + } + return Task.CompletedTask; + } + + public Task RevokeOtherChainsAsync(UserKey user, SessionChainId keepChain, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var (id, chain) in _chains) + { + if (chain.Tenant != _tenant) + continue; + + if (chain.UserKey != user) + continue; + + if (id == keepChain) + continue; + + if (!chain.IsRevoked) + _chains[id] = chain.Revoke(at); + } + + return Task.CompletedTask; + } + + public Task RevokeAllChainsAsync(UserKey user, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var (id, chain) in _chains) + { + if (chain.Tenant != _tenant) + continue; + + if (chain.UserKey != user) + continue; + + if (!chain.IsRevoked) + _chains[id] = chain.Revoke(at); + } + + return Task.CompletedTask; + } + + public Task GetRootByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_roots.TryGetValue((_tenant, userKey), out var r) ? r : null); + } + + public Task GetRootByIdAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(_roots.Values.FirstOrDefault(r => r.RootId == rootId)); + } + + public Task SaveRootAsync(UAuthSessionRoot root, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (!_roots.TryGetValue((_tenant, root.UserKey), out var current)) + throw new UAuthNotFoundException("root_not_found"); + + if (current.Version != expectedVersion) + throw new UAuthConcurrencyException("root_concurrency_conflict"); + + _roots[(_tenant, root.UserKey)] = root; + return Task.CompletedTask; + } + + public Task CreateRootAsync(UAuthSessionRoot root, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (_roots.ContainsKey((_tenant, root.UserKey))) + throw new UAuthConcurrencyException("root_already_exists"); + + if (root.Version != 0) + throw new InvalidOperationException("New root must have version 0."); + + _roots[(_tenant, root.UserKey)] = root; + } + + return Task.CompletedTask; + } + + public Task RevokeRootAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_roots.TryGetValue((_tenant, userKey), out var root)) + { + _roots[(_tenant, userKey)] = root.Revoke(at); + } + return Task.CompletedTask; + } + + public Task GetChainIdBySessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_sessions.TryGetValue(sessionId, out var session)) + return Task.FromResult(session.ChainId); + + return Task.FromResult(null); + } + + public Task> GetChainsByUserAsync(UserKey userKey, bool includeHistoricalRoots = false, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var roots = _roots.Values.Where(r => r.UserKey == userKey); + + if (!includeHistoricalRoots) + { + roots = roots.Where(r => !r.IsRevoked); + } + + var rootIds = roots.Select(r => r.RootId).ToHashSet(); + + var result = _chains.Values.Where(c => rootIds.Contains(c.RootId)).ToList().AsReadOnly(); + return Task.FromResult>(result); + } + + public Task> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = _chains.Values.Where(c => c.RootId == rootId).ToList().AsReadOnly(); + return Task.FromResult>(result); + } + + public Task GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var chain = _chains.Values + .FirstOrDefault(c => + c.Tenant == _tenant && + c.UserKey == userKey && + !c.IsRevoked && + c.Device.DeviceId == deviceId); + + return Task.FromResult(chain); + } + + public Task> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + return Task.FromResult>(result); + } + + public Task RemoveSessionAsync(AuthSessionId sessionId, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_sessions.TryGetValue(sessionId, out var session)) + return Task.CompletedTask; + + _sessions.TryRemove(sessionId, out _); + + if (_chains.TryGetValue(session.ChainId, out var chain)) + { + if (chain.ActiveSessionId == sessionId) + { + var updatedChain = chain.DetachSession(DateTimeOffset.UtcNow); + _chains[chain.ChainId] = updatedChain; + } + } + } + + return Task.CompletedTask; + } + + public Task RevokeChainCascadeAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_chains.TryGetValue(chainId, out var chain)) + return Task.CompletedTask; + + if (!chain.IsRevoked) + { + var revokedChain = chain.Revoke(at); + _chains[chainId] = revokedChain; + } + + var sessions = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + } + + return Task.CompletedTask; + } + + public Task LogoutChainAsync(SessionChainId chainId, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_chains.TryGetValue(chainId, out var chain)) + return Task.CompletedTask; + + if (chain.IsRevoked) + return Task.CompletedTask; + + var sessions = _sessions.Values.Where(s => s.ChainId == chainId).ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + + if (chain.ActiveSessionId is not null) + { + var updatedChain = chain.DetachSession(at); + _chains[chainId] = updatedChain; + } + } + + return Task.CompletedTask; + } + + public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + lock (_lock) + { + if (!_roots.TryGetValue((_tenant, userKey), out var root)) + return Task.CompletedTask; + + var chains = _chains.Values + .Where(c => c.UserKey == userKey && c.Tenant == root.Tenant) + .ToList(); + + foreach (var chain in chains) + { + if (!chain.IsRevoked) + { + var revokedChain = chain.Revoke(at); + _chains[chain.ChainId] = revokedChain; + } + + var sessions = _sessions.Values + .Where(s => s.ChainId == chain.ChainId) + .ToList(); + + foreach (var session in sessions) + { + if (!session.IsRevoked) + { + var revokedSession = session.Revoke(at); + _sessions[session.SessionId] = revokedSession; + } + } + } + + if (!root.IsRevoked) + { + var revokedRoot = root.Revoke(at); + _roots[(_tenant, userKey)] = revokedRoot; + } + } + + return Task.CompletedTask; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs new file mode 100644 index 00000000..b0fdf3c8 --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStoreFactory.cs @@ -0,0 +1,15 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory; + +public sealed class InMemorySessionStoreFactory : ISessionStoreFactory +{ + private readonly ConcurrentDictionary _kernels = new(); + + public ISessionStore Create(TenantKey tenant) + { + return _kernels.GetOrAdd(tenant, t => new InMemorySessionStore(t)); + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/README.md b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/README.md new file mode 100644 index 00000000..4ec9238f --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Sessions InMemory + +In-memory session persistence for UltimateAuth. + +## Purpose + +Provides lightweight session storage for: + +- Active sessions +- Session lifecycle tracking +- Session validation + +## When to use + +- Development +- Testing +- Local environments + +## โš ๏ธ Not for production + +All session data is stored in memory and will be lost when the application restarts. + +## Notes + +- Zero configuration +- No external dependencies + +## Use instead (production) + +- CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore +- Custom session persistence \ No newline at end of file diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..adb0976c --- /dev/null +++ b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Sessions.InMemory.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthSessionsInMemory(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/logo.png b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/logo.png differ diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj new file mode 100644 index 00000000..18cd4640 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.csproj @@ -0,0 +1,30 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore + + + Entity Framework Core token persistence for UltimateAuth. + Provides durable storage for refresh tokens and token lifecycle management. + + + authentication;tokens;efcore;jwt;refresh-token;database;auth-framework + + README.md + logo.png + + + + + + + + + + + + + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs new file mode 100644 index 00000000..032fc0cd --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenDbContext.cs @@ -0,0 +1,19 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public sealed class UAuthTokenDbContext : DbContext +{ + public DbSet RefreshTokens => Set(); + //public DbSet RevokedTokenIds => Set(); // TODO: Add when JWT added. + + public UAuthTokenDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UAuthTokensModelBuilder.Configure(modelBuilder); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenModelBuilder.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenModelBuilder.cs new file mode 100644 index 00000000..f6f1b026 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Data/UAuthTokenModelBuilder.cs @@ -0,0 +1,73 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public static class UAuthTokensModelBuilder +{ + public static void Configure(ModelBuilder b) + { + ConfigureRefreshTokens(b); + } + + private static void ConfigureRefreshTokens(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_RefreshTokens"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Version).IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion( + v => v.Value, + v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion( + v => v.Value, + v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.TokenId) + .HasConversion( + v => v.Value, + v => TokenId.From(v)) + .IsRequired(); + + e.Property(x => x.TokenHash) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.SessionId) + .HasConversion(new AuthSessionIdConverter()); + + e.Property(x => x.ChainId) + .HasConversion(new NullableSessionChainIdConverter()); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.ExpiresAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.RevokedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.ReplacedByTokenHash) + .HasMaxLength(128); + + e.HasIndex(x => new { x.Tenant, x.TokenHash }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.TokenHash, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.TokenId }); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.SessionId }); + e.HasIndex(x => new { x.Tenant, x.ChainId }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt }); + e.HasIndex(x => new { x.Tenant, x.ExpiresAt, x.RevokedAt }); + e.HasIndex(x => new { x.Tenant, x.ReplacedByTokenHash }); + }); + } +} \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..11410052 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthTokensEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext + { + if (configureDb != null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + return services; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs new file mode 100644 index 00000000..d7c2e41c --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Mappers/RefreshTokenProjectionMapper.cs @@ -0,0 +1,43 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal static class RefreshTokenProjectionMapper +{ + public static RefreshToken ToDomain(this RefreshTokenProjection p) + { + return RefreshToken.Create( + tokenId: p.TokenId, + tokenHash: p.TokenHash, + tenant: p.Tenant, + userKey: p.UserKey, + sessionId: p.SessionId, + chainId: p.ChainId, + createdAt: p.CreatedAt, + expiresAt: p.ExpiresAt + ) with + { + RevokedAt = p.RevokedAt, + ReplacedByTokenHash = p.ReplacedByTokenHash, + Version = p.Version + }; + } + + public static RefreshTokenProjection ToProjection(this RefreshToken t) + { + return new RefreshTokenProjection + { + TokenId = t.TokenId, + Tenant = t.Tenant, + TokenHash = t.TokenHash, + UserKey = t.UserKey, + SessionId = t.SessionId, + ChainId = t.ChainId, + CreatedAt = t.CreatedAt, + ExpiresAt = t.ExpiresAt, + RevokedAt = t.RevokedAt, + ReplacedByTokenHash = t.ReplacedByTokenHash, + Version = t.Version + }; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs new file mode 100644 index 00000000..2e67b489 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RefreshTokenProjection.cs @@ -0,0 +1,29 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +public sealed class RefreshTokenProjection +{ + public long Id { get; set; } // EF PK + + public TokenId TokenId { get; set; } + + public TenantKey Tenant { get; set; } + + public string TokenHash { get; set; } = default!; + + public UserKey UserKey { get; set; } = default!; + + public AuthSessionId SessionId { get; set; } = default!; + + public SessionChainId? ChainId { get; set; } + + public string? ReplacedByTokenHash { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset? RevokedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs new file mode 100644 index 00000000..72418bec --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Projections/RevokedIdTokenProjection.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class RevokedTokenIdProjection +{ + public long Id { get; set; } + public TenantKey Tenant { get; set; } + + public string Jti { get; set; } = default!; + + public DateTimeOffset ExpiresAt { get; set; } + public DateTimeOffset RevokedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/README.md b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/README.md new file mode 100644 index 00000000..0661c32c --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/README.md @@ -0,0 +1,33 @@ +๏ปฟ# UltimateAuth Tokens EntityFrameworkCore + +Entity Framework Core persistence for UltimateAuth tokens. + +## Purpose + +Provides durable storage for token-related data: + +- Refresh tokens +- Token rotation state +- Token revocation tracking + +## Features + +- Persistent token storage +- Refresh token rotation support +- Revocation tracking + +## Notes + +- Requires EF Core configuration +- Migrations must be handled by the application + +## When to use + +- Production environments +- Distributed systems +- Scalable architectures + +## Alternatives + +- CodeBeam.UltimateAuth.Tokens.InMemory (development only) +- Custom packages \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs new file mode 100644 index 00000000..b4be2ef5 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStore.cs @@ -0,0 +1,181 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreRefreshTokenStore : IRefreshTokenStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + private bool _inTransaction; + + public EfCoreRefreshTokenStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + await strategy.ExecuteAsync(async () => + { + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + _inTransaction = true; + + try + { + await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _inTransaction = false; + } + }); + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + var strategy = _db.Database.CreateExecutionStrategy(); + + return await strategy.ExecuteAsync(async () => + { + await using var tx = await _db.Database.BeginTransactionAsync(ct); + + _inTransaction = true; + + try + { + var result = await action(ct); + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + return result; + } + catch + { + await tx.RollbackAsync(ct); + throw; + } + finally + { + _inTransaction = false; + } + }); + } + + private void EnsureTransaction() + { + if (!_inTransaction) + throw new InvalidOperationException("Operation must be executed inside ExecuteAsync."); + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + DbSet.Add(token.ToProjection()); + + return Task.CompletedTask; + } + + public async Task FindByHashAsync(string tokenHash, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var p = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.TokenHash == tokenHash, + ct); + + return p?.ToDomain(); + } + + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + + var query = DbSet + .Where(x => + x.Tenant == _tenant && + x.TokenHash == tokenHash && + x.RevokedAt == null); + + if (replacedByTokenHash is null) + { + return query.ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + return query.ExecuteUpdateAsync( + x => x + .SetProperty(t => t.RevokedAt, revokedAt) + .SetProperty(t => t.ReplacedByTokenHash, replacedByTokenHash), + ct); + } + + public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + + return DbSet + .Where(x => + x.Tenant == _tenant && + x.SessionId == sessionId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + + return DbSet + .Where(x => + x.Tenant == _tenant && + x.ChainId == chainId && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } + + public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + EnsureTransaction(); + + return DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.RevokedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(t => t.RevokedAt, revokedAt), + ct); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..9cc94371 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/Stores/EfCoreRefreshTokenStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; + +internal sealed class EfCoreRefreshTokenStoreFactory : IRefreshTokenStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreRefreshTokenStoreFactory(TDbContext db) + { + _db = db; + } + + public IRefreshTokenStore Create(TenantKey tenant) + { + return new EfCoreRefreshTokenStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/logo.png b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/tokens/CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore/logo.png differ diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj new file mode 100644 index 00000000..90b2b5c8 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/CodeBeam.UltimateAuth.Tokens.InMemory.csproj @@ -0,0 +1,30 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Tokens.InMemory + + + In-memory token persistence for UltimateAuth. + Provides lightweight storage for refresh tokens and related token state. + Suitable for development and testing scenarios only. + + + authentication;tokens;inmemory;jwt;refresh-token;auth-framework + logo.png + README.md + + + + + + + + + + + + + diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs new file mode 100644 index 00000000..a5787f95 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStore.cs @@ -0,0 +1,134 @@ +๏ปฟusing System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +internal sealed class InMemoryRefreshTokenStore : IRefreshTokenStore +{ + private readonly TenantKey _tenant; + private readonly SemaphoreSlim _tx = new(1, 1); + + private readonly ConcurrentDictionary<(TenantKey, string), RefreshToken> _tokens = new(); + + public InMemoryRefreshTokenStore(TenantKey tenant) + { + _tenant = tenant; + } + + public async Task ExecuteAsync(Func action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + + try + { + await action(ct); + } + finally + { + _tx.Release(); + } + } + + public async Task ExecuteAsync(Func> action, CancellationToken ct = default) + { + await _tx.WaitAsync(ct); + + try + { + return await action(ct); + } + finally + { + _tx.Release(); + } + } + + public Task StoreAsync(RefreshToken token, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (token.Tenant != _tenant) + throw new InvalidOperationException("Tenant mismatch."); + + _tokens[(_tenant, token.TokenHash)] = token; + + return Task.CompletedTask; + } + + public Task FindByHashAsync(string tokenHash, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + _tokens.TryGetValue((_tenant, tokenHash), out var token); + + return Task.FromResult(token); + } + + public Task RevokeAsync(string tokenHash, DateTimeOffset revokedAt, string? replacedByTokenHash = null, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (_tokens.TryGetValue((_tenant, tokenHash), out var token) && !token.IsRevoked) + { + _tokens[(_tenant, tokenHash)] = token.Revoke(revokedAt, replacedByTokenHash); + } + + return Task.CompletedTask; + } + + public Task RevokeBySessionAsync(AuthSessionId sessionId, DateTimeOffset revokedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var ((tenant, hash), token) in _tokens.ToArray()) + { + if (tenant != _tenant) + continue; + + if (token.SessionId == sessionId && !token.IsRevoked) + { + _tokens[(_tenant, hash)] = token.Revoke(revokedAt); + } + } + + return Task.CompletedTask; + } + + public Task RevokeByChainAsync(SessionChainId chainId, DateTimeOffset revokedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var ((tenant, hash), token) in _tokens.ToArray()) + { + if (tenant != _tenant) + continue; + + if (token.ChainId == chainId && !token.IsRevoked) + { + _tokens[(_tenant, hash)] = token.Revoke(revokedAt); + } + } + + return Task.CompletedTask; + } + + public Task RevokeAllForUserAsync(UserKey userKey, DateTimeOffset revokedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + foreach (var ((tenant, hash), token) in _tokens.ToArray()) + { + if (tenant != _tenant) + continue; + + if (token.UserKey == userKey && !token.IsRevoked) + { + _tokens[(_tenant, hash)] = token.Revoke(revokedAt); + } + } + + return Task.CompletedTask; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs new file mode 100644 index 00000000..e749cc38 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/InMemoryRefreshTokenStoreFactory.cs @@ -0,0 +1,15 @@ +๏ปฟusing System.Collections.Concurrent; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory; + +public sealed class InMemoryRefreshTokenStoreFactory : IRefreshTokenStoreFactory +{ + private readonly ConcurrentDictionary _stores = new(); + + public IRefreshTokenStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, _ => new InMemoryRefreshTokenStore(tenant)); + } +} \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/README.md b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/README.md new file mode 100644 index 00000000..7f3d0400 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/README.md @@ -0,0 +1,31 @@ +๏ปฟ# UltimateAuth Tokens InMemory + +In-memory token persistence for UltimateAuth. + +## Purpose + +This package provides in-memory storage for token-related data: + +- Refresh tokens +- Token metadata +- Token lifecycle state + +## When to use + +- Development +- Testing +- Local environments + +## โš ๏ธ Not for production + +All token data is stored in memory and will be lost when the application restarts. + +## Notes + +- No configuration required +- Lightweight and dependency-free + +## Use instead (production) + +- CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore +- Custom token persistence \ No newline at end of file diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..3b5868d4 --- /dev/null +++ b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/ServiceCollectionExtensions.cs @@ -0,0 +1,13 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tokens.InMemory.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthTokensInMemory(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/logo.png b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/tokens/CodeBeam.UltimateAuth.Tokens.InMemory/logo.png differ diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj new file mode 100644 index 00000000..c21bbf8c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/CodeBeam.UltimateAuth.Users.Contracts.csproj @@ -0,0 +1,29 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Users.Contracts + + + Shared contracts and cross-boundary types for UltimateAuth Users module. + This package contains identifiers, DTOs and shared models used between client and server. + It does NOT contain domain logic or persistence. + + + authentication;identity;users;contracts;shared;dto;auth-framework + logo.png + README.md + + + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/AdminAssignableUserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/AdminAssignableUserStatus.cs new file mode 100644 index 00000000..035ec61c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/AdminAssignableUserStatus.cs @@ -0,0 +1,17 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum AdminAssignableUserStatus +{ + Active = 0, + + Disabled = 20, + Suspended = 30, + + Locked = 40, + RiskHold = 50, + + PendingActivation = 60, + PendingVerification = 70, + + Unknown = 100 +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs new file mode 100644 index 00000000..e2201471 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceQuery.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceQuery( + UserIdentifierType Type, + string NormalizedValue, + IdentifierExistenceScope Scope, + UserKey? UserKey = null, + Guid? ExcludeIdentifierId = null + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs new file mode 100644 index 00000000..ba2d8f33 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/IdentifierExistenceScope.cs @@ -0,0 +1,19 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum IdentifierExistenceScope +{ + /// + /// Checks only within the same user. + /// + WithinUser, + + /// + /// Checks within tenant but only primary identifiers. + /// + TenantPrimaryOnly, + + /// + /// Checks within tenant regardless of primary flag. + /// + TenantAny +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs new file mode 100644 index 00000000..f207ae2c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/MfaMethod.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum MfaMethod +{ + Totp = 10, + Sms = 20, + Email = 30, + Passkey = 40 +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/PrimaryUserIdentifiers.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/PrimaryUserIdentifiers.cs new file mode 100644 index 00000000..b8b2cef5 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/PrimaryUserIdentifiers.cs @@ -0,0 +1,10 @@ +๏ปฟ +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record PrimaryUserIdentifiers +{ + public string? UserName { get; init; } + public string? Email { get; init; } + public string? Phone { get; init; } + public string? DisplayName { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfAssignableUserStatus.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfAssignableUserStatus.cs new file mode 100644 index 00000000..2ceed312 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/SelfAssignableUserStatus.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum SelfAssignableUserStatus +{ + Active = 0, + SelfSuspended = 10, +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs new file mode 100644 index 00000000..0b1b6b39 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserLifecycleSnapshot.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserLifecycleSnapshot +{ + public UserKey UserKey { get; init; } + public UserStatus Status { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs new file mode 100644 index 00000000..b6b5b4b0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/Snapshots/UserProfileSnapshot.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserProfileSnapshot +{ + public string? DisplayName { get; init; } + public string? Language { get; init; } + public string? TimeZone { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierInfo.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierInfo.cs new file mode 100644 index 00000000..4c3d381a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierInfo.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierInfo : IVersionedEntity +{ + public Guid Id { get; set; } + public required UserIdentifierType Type { get; set; } + public required string Value { get; set; } + public string NormalizedValue { get; set; } = default!; + public bool IsPrimary { get; set; } + public bool IsVerified { get; set; } + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; set; } + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs new file mode 100644 index 00000000..7267c203 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserIdentifierType.cs @@ -0,0 +1,9 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public enum UserIdentifierType +{ + Username, + Email, + Phone, + Custom +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs new file mode 100644 index 00000000..ae13171f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserMfaStatusInfo.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserMfaStatusInfo +{ + public bool IsEnabled { get; init; } + public IReadOnlyCollection EnabledMethods { get; init; } = Array.Empty(); + public MfaMethod? DefaultMethod { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs new file mode 100644 index 00000000..43e1fcf8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserQuery.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserQuery : PageRequest +{ + public string? Search { get; set; } + public UserStatus? Status { get; set; } + public bool IncludeDeleted { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs new file mode 100644 index 00000000..53763ee7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserSummary.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserSummary +{ + public UserKey UserKey { get; init; } = default!; + + public string? UserName { get; init; } + + public string? DisplayName { get; init; } + + public string? PrimaryEmail { get; init; } + + public string? PrimaryPhone { get; init; } + + public UserStatus Status { get; init; } + + public DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs new file mode 100644 index 00000000..b245402d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Dtos/UserView.cs @@ -0,0 +1,32 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserView +{ + public UserKey UserKey { get; init; } = default!; + public UserStatus Status { get; init; } + + public string? UserName { get; init; } + public string? PrimaryEmail { get; init; } + public string? PrimaryPhone { get; init; } + + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + public string? Bio { get; init; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + + public bool EmailVerified { get; init; } + public bool PhoneVerified { get; init; } + + public DateTimeOffset? CreatedAt { get; init; } + //public DateTimeOffset? LastLoginAt { get; init; } + + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs new file mode 100644 index 00000000..2effff52 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Mappers/UserStatusMapper.cs @@ -0,0 +1,69 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public static class UserStatusMapper +{ + public static UserStatus ToUserStatus(this SelfAssignableUserStatus selfStatus) + { + switch (selfStatus) + { + case SelfAssignableUserStatus.Active: + return UserStatus.Active; + case SelfAssignableUserStatus.SelfSuspended: + return UserStatus.SelfSuspended; + default: + throw new NotImplementedException(); + } + } + + public static UserStatus ToUserStatus(this AdminAssignableUserStatus status) + { + switch (status) + { + case AdminAssignableUserStatus.Active: + return UserStatus.Active; + case AdminAssignableUserStatus.Suspended: + return UserStatus.Suspended; + case AdminAssignableUserStatus.Disabled: + return UserStatus.Disabled; + case AdminAssignableUserStatus.Locked: + return UserStatus.Locked; + case AdminAssignableUserStatus.RiskHold: + return UserStatus.RiskHold; + case AdminAssignableUserStatus.PendingActivation: + return UserStatus.PendingActivation; + case AdminAssignableUserStatus.PendingVerification: + return UserStatus.PendingActivation; + case AdminAssignableUserStatus.Unknown: + return UserStatus.Unknown; + default: + throw new NotImplementedException(); + } + } + + public static AdminAssignableUserStatus ToAdminAssignableUserStatus(this UserStatus status) + { + switch (status) + { + case UserStatus.Active: + return AdminAssignableUserStatus.Active; + case UserStatus.Suspended: + return AdminAssignableUserStatus.Suspended; + case UserStatus.Disabled: + return AdminAssignableUserStatus.Disabled; + case UserStatus.Locked: + return AdminAssignableUserStatus.Locked; + case UserStatus.RiskHold: + return AdminAssignableUserStatus.RiskHold; + case UserStatus.PendingActivation: + return AdminAssignableUserStatus.PendingActivation; + case UserStatus.PendingVerification: + return AdminAssignableUserStatus.PendingActivation; + case UserStatus.Unknown: + return AdminAssignableUserStatus.Unknown; + default: + throw new NotImplementedException(); + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/README.md b/src/users/CodeBeam.UltimateAuth.Users.Contracts/README.md new file mode 100644 index 00000000..2e21436e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/README.md @@ -0,0 +1,27 @@ +๏ปฟ# UltimateAuth Users Contracts + +Shared contracts and cross-boundary models for the Users module. + +## Purpose + +This package contains DTOs, shared query models etc. + +## Does NOT include + +- Domain logic +- Persistence + +## Usage + +This package is used by: + +- Server implementations +- Client SDKs +- Custom user providers + +โš ๏ธ This package is usually installed transitively via: + +- CodeBeam.UltimateAuth.Server +- CodeBeam.UltimateAuth.Client + +No need to install it directly in most scenarios. \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs new file mode 100644 index 00000000..745b40d3 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/AddUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record AddUserIdentifierRequest +{ + public UserIdentifierType Type { get; init; } + public required string Value { get; init; } + public bool IsPrimary { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs new file mode 100644 index 00000000..d417d6a8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserIdentifierRequest.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record ChangeUserIdentifierRequest +{ + public required UserIdentifierType Type { get; init; } + public required string NewValue { get; init; } + public string? Reason { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs new file mode 100644 index 00000000..68e33657 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusAdminRequest.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record ChangeUserStatusAdminRequest +{ + public required AdminAssignableUserStatus NewStatus { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs new file mode 100644 index 00000000..f19968a2 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/ChangeUserStatusSelfRequest.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record ChangeUserStatusSelfRequest +{ + public required SelfAssignableUserStatus NewStatus { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs new file mode 100644 index 00000000..e078eb69 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/CreateUserRequest.cs @@ -0,0 +1,25 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record CreateUserRequest +{ + public string? UserName { get; init; } + public bool UserNameVerified { get; init; } + public string? Email { get; init; } + public bool EmailVerified { get; init; } + public string? Phone { get; init; } + public bool PhoneVerified { get; init; } + + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + + public string? Password { get; init; } + + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs new file mode 100644 index 00000000..e63a67ba --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserIdentifierRequest.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record DeleteUserIdentifierRequest +{ + public Guid Id { get; init; } + public DeleteMode Mode { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs new file mode 100644 index 00000000..d416b9ee --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/DeleteUserRequest.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record DeleteUserRequest +{ + public DeleteMode Mode { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs new file mode 100644 index 00000000..72218125 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/IdentifierExistsRequest.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistsRequest +{ + public UserIdentifierType Type { get; init; } + public required string Value { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceRequest.cs new file mode 100644 index 00000000..6ed81237 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutDeviceRequest.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record LogoutDeviceRequest +{ + public required SessionChainId ChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs new file mode 100644 index 00000000..ec7763df --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/LogoutOtherDevicesRequest.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record LogoutOtherDevicesRequest +{ + public required SessionChainId CurrentChainId { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs new file mode 100644 index 00000000..cbc4924f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/RegisterUserRequest.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +/// +/// Request to register a new user with credentials. +/// +public sealed record RegisterUserRequest +{ + /// + /// Unique user identifier (username, email, or external id). + /// Interpretation is application-specific. + /// + public required string Identifier { get; init; } + + /// + /// Plain-text password. + /// Will be hashed by the configured password hasher. + /// + public required string Password { get; init; } + + /// + /// Optional tenant identifier. + /// + public TenantKey Tenant { get; init; } + + /// + /// Optional initial claims or metadata. + /// + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs new file mode 100644 index 00000000..e396c161 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/SetPrimaryUserIdentifierRequest.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record SetPrimaryUserIdentifierRequest +{ + public Guid Id { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs new file mode 100644 index 00000000..8e7675db --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UnsetPrimaryUserIdentifierRequest.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UnsetPrimaryUserIdentifierRequest +{ + public Guid Id { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs new file mode 100644 index 00000000..018aac82 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateProfileRequest.cs @@ -0,0 +1,17 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UpdateProfileRequest +{ + public string? FirstName { get; init; } + public string? LastName { get; init; } + public string? DisplayName { get; init; } + public DateOnly? BirthDate { get; init; } + public string? Gender { get; init; } + public string? Bio { get; init; } + + public string? Language { get; init; } + public string? TimeZone { get; init; } + public string? Culture { get; init; } + + public IReadOnlyDictionary? Metadata { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs new file mode 100644 index 00000000..60b81875 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UpdateUserIdentifierRequest.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UpdateUserIdentifierRequest +{ + public Guid Id { get; init; } + public required string NewValue { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs new file mode 100644 index 00000000..40233262 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/UserIdentifierQuery.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserIdentifierQuery : PageRequest +{ + public UserKey? UserKey { get; init; } + + public bool IncludeDeleted { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs new file mode 100644 index 00000000..205c5460 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Requests/VerifyUserIdentifierRequest.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record VerifyUserIdentifierRequest +{ + public Guid Id { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs new file mode 100644 index 00000000..7fb41c67 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/BeginMfaSetupResult.cs @@ -0,0 +1,8 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record BeginMfaSetupResult +{ + public MfaMethod Method { get; init; } + public string? SharedSecret { get; init; } // TOTP + public string? QrCodeUri { get; init; } // TOTP +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs new file mode 100644 index 00000000..4a78263a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/GetUserIdentifiersResult.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record GetUserIdentifiersResult +{ + public required IReadOnlyCollection Identifiers { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs new file mode 100644 index 00000000..da9f1da4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierChangeResult.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierChangeResult +{ + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } + + public static IdentifierChangeResult Success() => new() { Succeeded = true }; + + public static IdentifierChangeResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs new file mode 100644 index 00000000..00145603 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierDeleteResult.cs @@ -0,0 +1,10 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierDeleteResult +{ + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } + + public static IdentifierDeleteResult Success() => new() { Succeeded = true }; + public static IdentifierDeleteResult Fail(string reason) => new() { Succeeded = false, FailureReason = reason }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs new file mode 100644 index 00000000..ec234ff4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistenceResult.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierExistenceResult( + bool Exists, + UserKey? OwnerUserKey = null, + Guid? OwnerIdentifierId = null, + bool OwnerIsPrimary = false + ); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs new file mode 100644 index 00000000..0d2150d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierExistsResponse.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierExistsResponse +{ + public bool Exists { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs new file mode 100644 index 00000000..f79173ef --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierValidationResult.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class IdentifierValidationResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private IdentifierValidationResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static IdentifierValidationResult Success() + => new(true, Array.Empty()); + + public static IdentifierValidationResult Failed(IEnumerable errors) + => new(false, errors.ToList()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs new file mode 100644 index 00000000..b642949d --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/IdentifierVerificationResult.cs @@ -0,0 +1,11 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record IdentifierVerificationResult +{ + public bool Succeeded { get; init; } + public string? FailureReason { get; init; } + + public static IdentifierVerificationResult Success() => new() { Succeeded = true }; + + public static IdentifierVerificationResult Failed(string reason) => new() { Succeeded = false, FailureReason = reason }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs new file mode 100644 index 00000000..66d8ef35 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateResult.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserCreateResult +{ + public required bool Succeeded { get; init; } + + /// + /// Created user's key (string form of UserKey). + /// Available only when Succeeded = true. + /// + public string? UserKey { get; init; } + + public string? FailureReason { get; init; } + + public static UserCreateResult Success(UserKey userKey) + => new() + { + Succeeded = true, + UserKey = userKey.Value + }; + + public static UserCreateResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs new file mode 100644 index 00000000..90f48c27 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserCreateValidatorResult.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed class UserCreateValidatorResult +{ + public bool IsValid { get; } + + public IReadOnlyList Errors { get; } + + private UserCreateValidatorResult(bool isValid, IReadOnlyList errors) + { + IsValid = isValid; + Errors = errors; + } + + public static UserCreateValidatorResult Success() + => new(true, Array.Empty()); + + public static UserCreateValidatorResult Failed(IEnumerable errors) + => new(false, errors.ToList().AsReadOnly()); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs new file mode 100644 index 00000000..52886f50 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserDeleteResult.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserDeleteResult +{ + public required bool Succeeded { get; init; } + + public required DeleteMode Mode { get; init; } + + public string? FailureReason { get; init; } + + public static UserDeleteResult Success(DeleteMode mode) + => new() + { + Succeeded = true, + Mode = mode + }; + + public static UserDeleteResult NotFound() + => new() + { + Succeeded = false, + Mode = DeleteMode.Soft, + FailureReason = "User not found." + }; + + public static UserDeleteResult AlreadyDeleted(DeleteMode mode) + => new() + { + Succeeded = true, + Mode = mode + }; + + public static UserDeleteResult Failed(DeleteMode mode, string reason) + => new() + { + Succeeded = false, + Mode = mode, + FailureReason = reason + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs new file mode 100644 index 00000000..25706af4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Contracts/Responses/UserStatusChangeResult.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Contracts; + +public sealed record UserStatusChangeResult +{ + public required bool Succeeded { get; init; } + + public UserStatus? PreviousStatus { get; init; } + + public UserStatus? CurrentStatus { get; init; } + + public string? FailureReason { get; init; } + + public static UserStatusChangeResult Success(UserStatus previous, UserStatus current) + => new() + { + Succeeded = true, + PreviousStatus = previous, + CurrentStatus = current + }; + + public static UserStatusChangeResult NoChange(UserStatus status) + => new() + { + Succeeded = true, + PreviousStatus = status, + CurrentStatus = status + }; + + public static UserStatusChangeResult NotFound() + => new() + { + Succeeded = false, + FailureReason = "User not found." + }; + + public static UserStatusChangeResult Failed(string reason) + => new() + { + Succeeded = false, + FailureReason = reason + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Contracts/logo.png b/src/users/CodeBeam.UltimateAuth.Users.Contracts/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/users/CodeBeam.UltimateAuth.Users.Contracts/logo.png differ diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj new file mode 100644 index 00000000..6e53ba0c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/CodeBeam.UltimateAuth.Users.EntityFrameworkCore.csproj @@ -0,0 +1,30 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Users.EntityFrameworkCore + + + Entity Framework Core persistence implementation for the UltimateAuth Users module. + Provides production-ready storage using EF Core with support for relational databases. + + + authentication;users;efcore;database;sql;auth-framework + logo.png + README.md + + + + + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs new file mode 100644 index 00000000..88dbd3f2 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUserDbContext.cs @@ -0,0 +1,20 @@ +๏ปฟusing Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +public sealed class UAuthUserDbContext : DbContext +{ + public DbSet Lifecycles => Set(); + public DbSet Identifiers => Set(); + public DbSet Profiles => Set(); + + public UAuthUserDbContext(DbContextOptions options) + : base(options) + { + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + UAuthUsersModelBuilder.Configure(modelBuilder); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs new file mode 100644 index 00000000..edbf278c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Data/UAuthUsersModelBuilder.cs @@ -0,0 +1,122 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +public static class UAuthUsersModelBuilder +{ + public static void Configure(ModelBuilder b) + { + ConfigureIdentifiers(b); + ConfigureLifecycles(b); + ConfigureProfiles(b); + } + + private static void ConfigureIdentifiers(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_UserIdentifiers"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Value) + .HasMaxLength(256) + .IsRequired(); + + e.Property(x => x.NormalizedValue) + .HasMaxLength(256) + .IsRequired(); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.HasIndex(x => new { x.Tenant, x.Type, x.NormalizedValue }).IsUnique(); + e.HasIndex(x => new { x.Tenant, x.UserKey }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.Type, x.IsPrimary }); + e.HasIndex(x => new { x.Tenant, x.UserKey, x.IsPrimary }); + e.HasIndex(x => new { x.Tenant, x.NormalizedValue }); + }); + } + + private static void ConfigureLifecycles(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_UserLifecycles"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.Property(x => x.SecurityVersion) + .IsRequired(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }).IsUnique(); + }); + } + + private static void ConfigureProfiles(ModelBuilder b) + { + b.Entity(e => + { + e.ToTable("UAuth_UserProfiles"); + + e.HasKey(x => x.Id); + + e.Property(x => x.Version) + .IsConcurrencyToken(); + + e.Property(x => x.Tenant) + .HasConversion(v => v.Value, v => TenantKey.FromInternal(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.UserKey) + .HasConversion(v => v.Value, v => UserKey.FromString(v)) + .HasMaxLength(128) + .IsRequired(); + + e.Property(x => x.Metadata) + .HasConversion(new NullableJsonValueConverter>()) + .Metadata.SetValueComparer(JsonValueComparers.Create>()); + + e.Property(x => x.CreatedAt).HasUtcDateTimeOffsetConverter().IsRequired(); + e.Property(x => x.UpdatedAt).HasNullableUtcDateTimeOffsetConverter(); + e.Property(x => x.DeletedAt).HasNullableUtcDateTimeOffsetConverter(); + + e.HasIndex(x => new { x.Tenant, x.UserKey }); + }); + } +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..3c9162ab --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,21 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthUsersEntityFrameworkCore(this IServiceCollection services, Action? configureDb = null) where TDbContext : DbContext + { + if (configureDb is not null) + { + services.AddDbContext(configureDb); + } + + services.AddScoped>(); + services.AddScoped>(); + services.AddScoped>(); + return services; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs new file mode 100644 index 00000000..75a95e35 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserIdentifierMapper.cs @@ -0,0 +1,55 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal static class UserIdentifierMapper +{ + public static UserIdentifier ToDomain(this UserIdentifierProjection p) + { + return UserIdentifier.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.Type, + p.Value, + p.NormalizedValue, + p.IsPrimary, + p.CreatedAt, + p.VerifiedAt, + p.UpdatedAt, + p.DeletedAt, + p.Version); + } + + public static UserIdentifierProjection ToProjection(this UserIdentifier d) + { + return new UserIdentifierProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + Type = d.Type, + Value = d.Value, + NormalizedValue = d.NormalizedValue, + IsPrimary = d.IsPrimary, + CreatedAt = d.CreatedAt, + VerifiedAt = d.VerifiedAt, + UpdatedAt = d.UpdatedAt, + DeletedAt = d.DeletedAt, + Version = d.Version + }; + } + + public static void UpdateProjection(this UserIdentifier source, UserIdentifierProjection target) + { + // Don't touch identity and concurrency properties + + target.Value = source.Value; + target.NormalizedValue = source.NormalizedValue; + target.IsPrimary = source.IsPrimary; + + target.VerifiedAt = source.VerifiedAt; + target.UpdatedAt = source.UpdatedAt; + target.DeletedAt = source.DeletedAt; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs new file mode 100644 index 00000000..f7a35eb9 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserLifecycleMapper.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal static class UserLifecycleMapper +{ + public static UserLifecycle ToDomain(this UserLifecycleProjection p) + { + return UserLifecycle.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.Status, + p.SecurityVersion, + p.CreatedAt, + p.UpdatedAt, + p.DeletedAt, + p.Version); + } + + public static UserLifecycleProjection ToProjection(this UserLifecycle d) + { + return new UserLifecycleProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + Status = d.Status, + SecurityVersion = d.SecurityVersion, + CreatedAt = d.CreatedAt, + UpdatedAt = d.UpdatedAt, + DeletedAt = d.DeletedAt, + Version = d.Version + }; + } + + public static void UpdateProjection(this UserLifecycle source, UserLifecycleProjection target) + { + target.Status = source.Status; + target.SecurityVersion = source.SecurityVersion; + target.UpdatedAt = source.UpdatedAt; + target.DeletedAt = source.DeletedAt; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs new file mode 100644 index 00000000..aaa2addb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Mappers/UserProfileMapper.cs @@ -0,0 +1,72 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal static class UserProfileMapper +{ + public static UserProfile ToDomain(this UserProfileProjection p) + { + return UserProfile.FromProjection( + p.Id, + p.Tenant, + p.UserKey, + p.FirstName, + p.LastName, + p.DisplayName, + p.BirthDate, + p.Gender, + p.Bio, + p.Language, + p.TimeZone, + p.Culture, + p.Metadata, + p.CreatedAt, + p.UpdatedAt, + p.DeletedAt, + p.Version); + } + + public static UserProfileProjection ToProjection(this UserProfile d) + { + return new UserProfileProjection + { + Id = d.Id, + Tenant = d.Tenant, + UserKey = d.UserKey, + FirstName = d.FirstName, + LastName = d.LastName, + DisplayName = d.DisplayName, + BirthDate = d.BirthDate, + Gender = d.Gender, + Bio = d.Bio, + Language = d.Language, + TimeZone = d.TimeZone, + Culture = d.Culture, + Metadata = d.Metadata?.ToDictionary(x => x.Key, x => x.Value), + CreatedAt = d.CreatedAt, + UpdatedAt = d.UpdatedAt, + DeletedAt = d.DeletedAt, + Version = d.Version + }; + } + + public static void UpdateProjection(this UserProfile source, UserProfileProjection target) + { + target.DisplayName = source.DisplayName; + target.FirstName = source.FirstName; + target.LastName = source.LastName; + target.Metadata = source.Metadata?.ToDictionary(); + target.UpdatedAt = source.UpdatedAt; + target.DeletedAt = source.DeletedAt; + target.BirthDate = source.BirthDate; + target.Gender = source.Gender; + target.Bio = source.Bio; + target.Language = source.Language; + target.TimeZone = source.TimeZone; + target.Culture = source.Culture; + + // Version store-owned + // Id / Tenant / UserKey / CreatedAt immutable + } + +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs new file mode 100644 index 00000000..44ecf317 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserIdentifierProjections.cs @@ -0,0 +1,32 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +public sealed class UserIdentifierProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } = default!; + + public UserIdentifierType Type { get; set; } + + public string Value { get; set; } = default!; + + public string NormalizedValue { get; set; } = default!; + + public bool IsPrimary { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? VerifiedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs new file mode 100644 index 00000000..07ea8cde --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserLifecycleProjection.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +public sealed class UserLifecycleProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } = default!; + + public UserStatus Status { get; set; } + + public long SecurityVersion { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset? UpdatedAt { get; set; } + + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs new file mode 100644 index 00000000..90dfed20 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Projections/UserProfileProjection.cs @@ -0,0 +1,39 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +public sealed class UserProfileProjection +{ + public Guid Id { get; set; } + + public TenantKey Tenant { get; set; } + + public UserKey UserKey { get; set; } = default!; + + public string? FirstName { get; set; } + + public string? LastName { get; set; } + + public string? DisplayName { get; set; } + + public DateOnly? BirthDate { get; set; } + + public string? Gender { get; set; } + + public string? Bio { get; set; } + + public string? Language { get; set; } + + public string? TimeZone { get; set; } + + public string? Culture { get; set; } + + public Dictionary? Metadata { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset? UpdatedAt { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + + public long Version { get; set; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/README.md b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/README.md new file mode 100644 index 00000000..07ebd5f2 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/README.md @@ -0,0 +1,28 @@ +๏ปฟ# UltimateAuth Users EntityFrameworkCore + +Entity Framework Core persistence implementation for the UltimateAuth Users module. + +## Purpose + +This package provides a production-ready user storage implementation using EF Core. + +## Features + +- Persistent user storage +- Relational database support +- EF Core integration + +## Notes + +- Requires EF Core setup +- Migrations must be handled by the application + +## When to use + +- Production environments +- Applications requiring persistent storage + +## Alternatives + +- CodeBeam.UltimateAuth.Users.InMemory (development/testing) +- Custom persistence implementations \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs new file mode 100644 index 00000000..e3c4147e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EFCoreUserProfileStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserProfileStoreFactory : IUserProfileStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreUserProfileStoreFactory(TDbContext db) + { + _db = db; + } + + public IUserProfileStore Create(TenantKey tenant) + { + return new EfCoreUserProfileStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs new file mode 100644 index 00000000..e52bb943 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStore.cs @@ -0,0 +1,348 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserIdentifierStore : IUserIdentifierStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreUserIdentifierStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task ExistsAsync(Guid key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await DbSet + .AnyAsync(x => + x.Id == key && + x.Tenant == _tenant, + ct); + } + + public async Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var q = DbSet + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + x.DeletedAt == null); + + if (query.ExcludeIdentifierId.HasValue) + q = q.Where(x => x.Id != query.ExcludeIdentifierId.Value); + + q = query.Scope switch + { + IdentifierExistenceScope.WithinUser => + q.Where(x => x.UserKey == query.UserKey), + + IdentifierExistenceScope.TenantPrimaryOnly => + q.Where(x => x.IsPrimary), + + IdentifierExistenceScope.TenantAny => + q, + + _ => q + }; + + var match = await q + .Select(x => new + { + x.Id, + x.UserKey, + x.IsPrimary + }) + .FirstOrDefaultAsync(ct); + + if (match is null) + return new IdentifierExistenceResult(false); + + return new IdentifierExistenceResult( + true, + match.UserKey, + match.Id, + match.IsPrimary); + } + + public async Task GetAsync(Guid key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Id == key && + x.Tenant == _tenant, + ct); + + return projection?.ToDomain(); + } + + public async Task GetAsync(UserIdentifierType type, string normalizedValue, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync( + x => + x.Tenant == _tenant && + x.Type == type && + x.NormalizedValue == normalizedValue && + x.DeletedAt == null, + ct); + + return projection?.ToDomain(); + } + + public async Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Id == id && + x.Tenant == _tenant, + ct); + + return projection?.ToDomain(); + } + + public async Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSet + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .OrderBy(x => x.CreatedAt) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } + + public async Task AddAsync(UserIdentifier entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (entity.Version != 0) + throw new UAuthValidationException("New identifier must have version 0."); + + var projection = entity.ToProjection(); + + using var tx = await _db.Database.BeginTransactionAsync(ct); + + if (entity.IsPrimary) + { + await DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.IsPrimary && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(i => i.IsPrimary, false), + ct); + } + + DbSet.Add(projection); + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + + public async Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + using var tx = await _db.Database.BeginTransactionAsync(ct); + + if (entity.IsPrimary) + { + await DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x.SetProperty(i => i.IsPrimary, false), + ct); + } + + var existing = await DbSet + .SingleOrDefaultAsync(x => + x.Id == entity.Id && + x.Tenant == _tenant, + ct); + + if (existing is null) + throw new UAuthNotFoundException("identifier_not_found"); + + if (existing.Version != expectedVersion) + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); + + entity.UpdateProjection(existing); + + existing.Version++; + + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + + public async Task DeleteAsync(Guid key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .SingleOrDefaultAsync(x => + x.Id == key && + x.Tenant == _tenant, + ct); + + if (projection is null) + throw new UAuthNotFoundException("identifier_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("identifier_concurrency_conflict"); + + if (mode == DeleteMode.Hard) + { + DbSet.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.IsPrimary = false; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (mode == DeleteMode.Hard) + { + await DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey) + .ExecuteDeleteAsync(ct); + + return; + } + + await DbSet + .Where(x => + x.Tenant == _tenant && + x.UserKey == userKey && + x.DeletedAt == null) + .ExecuteUpdateAsync( + x => x + .SetProperty(i => i.DeletedAt, deletedAt) + .SetProperty(i => i.IsPrimary, false), + ct); + } + + public async Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (userKeys is null || userKeys.Count == 0) + return Array.Empty(); + + var projections = await DbSet + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + userKeys.Contains(x.UserKey) && + x.DeletedAt == null) + .OrderBy(x => x.UserKey) + .ThenBy(x => x.CreatedAt) + .ToListAsync(ct); + + return projections + .Select(x => x.ToDomain()) + .ToList(); + } + + public async Task> QueryAsync(UserIdentifierQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); + + var normalized = query.Normalize(); + + var baseQuery = DbSet + .AsNoTracking() + .Where(x => + x.Tenant == _tenant && + x.UserKey == query.UserKey); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); + + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), + + nameof(UserIdentifier.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserIdentifier.Value) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Value) + : baseQuery.OrderBy(x => x.Value), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(x => x.ToDomain()).ToList(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs new file mode 100644 index 00000000..2d343b42 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserIdentifierStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserIdentifierStoreFactory : IUserIdentifierStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreUserIdentifierStoreFactory(TDbContext db) + { + _db = db; + } + + public IUserIdentifierStore Create(TenantKey tenant) + { + return new EfCoreUserIdentifierStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs new file mode 100644 index 00000000..63c8ef35 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStore.cs @@ -0,0 +1,157 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserLifecycleStore : IUserLifecycleStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreUserLifecycleStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task GetAsync(UserLifecycleKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.UserKey == key.UserKey, + ct); + + return projection?.ToDomain(); + } + + public async Task ExistsAsync(UserLifecycleKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await DbSet + .AnyAsync( + x => x.Tenant == _tenant && + x.UserKey == key.UserKey, + ct); + } + + public async Task AddAsync(UserLifecycle entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (entity.Version != 0) + throw new InvalidOperationException("New lifecycle must have version 0."); + + var projection = entity.ToProjection(); + + DbSet.Add(projection); + + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(UserLifecycle entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var existing = await DbSet + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == entity.UserKey, + ct); + + if (existing is null) + throw new UAuthNotFoundException("user_lifecycle_not_found"); + + if (existing.Version != expectedVersion) + throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict"); + + entity.UpdateProjection(existing); + existing.Version++; + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(UserLifecycleKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .SingleOrDefaultAsync( + x => x.Tenant == _tenant && + x.UserKey == key.UserKey, + ct); + + if (projection is null) + throw new UAuthNotFoundException("user_lifecycle_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("user_lifecycle_concurrency_conflict"); + + if (mode == DeleteMode.Hard) + { + DbSet.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> QueryAsync(UserLifecycleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = DbSet + .AsNoTracking() + .Where(x => x.Tenant == _tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); + + if (query.Status != null) + baseQuery = baseQuery.Where(x => x.Status == query.Status); + + baseQuery = query.SortBy switch + { + nameof(UserLifecycle.Id) => + query.Descending ? baseQuery.OrderByDescending(x => x.Id) : baseQuery.OrderBy(x => x.Id), + + nameof(UserLifecycle.CreatedAt) => + query.Descending ? baseQuery.OrderByDescending(x => x.CreatedAt) : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserLifecycle.Status) => + query.Descending ? baseQuery.OrderByDescending(x => x.Status) : baseQuery.OrderBy(x => x.Status), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(x => x.ToDomain()).ToList(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs new file mode 100644 index 00000000..7e5c4d44 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserLifecycleStoreFactory.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserLifecycleStoreFactory : IUserLifecycleStoreFactory where TDbContext : DbContext +{ + private readonly TDbContext _db; + + public EfCoreUserLifecycleStoreFactory(TDbContext db) + { + _db = db; + } + + public IUserLifecycleStore Create(TenantKey tenant) + { + return new EfCoreUserLifecycleStore(_db, new TenantContext(tenant)); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs new file mode 100644 index 00000000..9623dc92 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/Stores/EfCoreUserProfileStore.cs @@ -0,0 +1,180 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Users.EntityFrameworkCore; + +internal sealed class EfCoreUserProfileStore : IUserProfileStore where TDbContext : DbContext +{ + private readonly TDbContext _db; + private readonly TenantKey _tenant; + + public EfCoreUserProfileStore(TDbContext db, TenantContext tenant) + { + _db = db; + _tenant = tenant.Tenant; + } + + private DbSet DbSet => _db.Set(); + + public async Task GetAsync(UserProfileKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .AsNoTracking() + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == key.UserKey, + ct); + + return projection?.ToDomain(); + } + + public async Task ExistsAsync(UserProfileKey key, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + return await DbSet + .AnyAsync(x => + x.Tenant == _tenant && + x.UserKey == key.UserKey, + ct); + } + + public async Task AddAsync(UserProfile entity, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = entity.ToProjection(); + + if (entity.Version != 0) + throw new InvalidOperationException("New profile must have version 0."); + + DbSet.Add(projection); + + await _db.SaveChangesAsync(ct); + } + + public async Task SaveAsync(UserProfile entity, long expectedVersion, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var existing = await DbSet + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == entity.UserKey, + ct); + + if (existing is null) + throw new UAuthNotFoundException("user_profile_not_found"); + + if (existing.Version != expectedVersion) + throw new UAuthConcurrencyException("user_profile_concurrency_conflict"); + + entity.UpdateProjection(existing); + existing.Version++; + + await _db.SaveChangesAsync(ct); + } + + public async Task DeleteAsync(UserProfileKey key, long expectedVersion, DeleteMode mode, DateTimeOffset now, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projection = await DbSet + .SingleOrDefaultAsync(x => + x.Tenant == _tenant && + x.UserKey == key.UserKey, + ct); + + if (projection is null) + throw new UAuthNotFoundException("user_profile_not_found"); + + if (projection.Version != expectedVersion) + throw new UAuthConcurrencyException("user_profile_concurrency_conflict"); + + if (mode == DeleteMode.Hard) + { + DbSet.Remove(projection); + } + else + { + projection.DeletedAt = now; + projection.Version++; + } + + await _db.SaveChangesAsync(ct); + } + + public async Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + + var baseQuery = DbSet + .AsNoTracking() + .Where(x => x.Tenant == _tenant); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => x.DeletedAt == null); + + baseQuery = query.SortBy switch + { + nameof(UserProfile.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserProfile.DisplayName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DisplayName) + : baseQuery.OrderBy(x => x.DisplayName), + + nameof(UserProfile.FirstName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.FirstName) + : baseQuery.OrderBy(x => x.FirstName), + + nameof(UserProfile.LastName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.LastName) + : baseQuery.OrderBy(x => x.LastName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var total = await baseQuery.CountAsync(ct); + + var items = await baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .ToListAsync(ct); + + return new PagedResult( + items.Select(x => x.ToDomain()).ToList(), + total, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending); + } + + public async Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var projections = await DbSet + .AsNoTracking() + .Where(x => x.Tenant == _tenant) + .Where(x => userKeys.Contains(x.UserKey)) + .Where(x => x.DeletedAt == null) + .ToListAsync(ct); + + return projections.Select(x => x.ToDomain()).ToList(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/logo.png b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/users/CodeBeam.UltimateAuth.Users.EntityFrameworkCore/logo.png differ diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj new file mode 100644 index 00000000..85195f3b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/CodeBeam.UltimateAuth.Users.InMemory.csproj @@ -0,0 +1,32 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Users.InMemory + + + In-memory persistence implementation for the UltimateAuth Users module. + Provides a lightweight, dependency-free storage option for development, testing and prototyping scenarios. + Not recommended for production use. + + + authentication;users;inmemory;testing;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..72310f36 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace CodeBeam.UltimateAuth.Users.InMemory.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthUsersInMemory(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/README.md b/src/users/CodeBeam.UltimateAuth.Users.InMemory/README.md new file mode 100644 index 00000000..ab4b96e8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/README.md @@ -0,0 +1,35 @@ +๏ปฟ# UltimateAuth Users InMemory + +In-memory persistence implementation for the UltimateAuth Users module. + +## Purpose + +This package provides a lightweight in-memory storage implementation for: + +- User data +- User identifiers +- Basic user lifecycle operations + +## When to use + +- Development environments +- Testing scenarios +- Prototyping + +## โš ๏ธ Not for production + +Data is stored in memory and will be lost when the application restarts. + +## Usage + +Once installed, this package provides default in-memory implementations. + +## Notes + +- No external dependencies +- Zero configuration required + +## Use instead (production) + +- CodeBeam.UltimateAuth.Users.EntityFrameworkCore +- Custom persistence providers \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs new file mode 100644 index 00000000..cd624dbc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStore.cs @@ -0,0 +1,228 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +public sealed class InMemoryUserIdentifierStore : InMemoryTenantVersionedStore, IUserIdentifierStore +{ + protected override Guid GetKey(UserIdentifier entity) => entity.Id; + private readonly object _primaryLock = new(); + + public InMemoryUserIdentifierStore(TenantContext tenant) : base(tenant) + { + + } + + public Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var candidates = TenantValues() + .Where(x => + x.Type == query.Type && + x.NormalizedValue == query.NormalizedValue && + !x.IsDeleted); + + if (query.ExcludeIdentifierId.HasValue) + candidates = candidates.Where(x => x.Id != query.ExcludeIdentifierId.Value); + + candidates = query.Scope switch + { + IdentifierExistenceScope.WithinUser => + candidates.Where(x => x.UserKey == query.UserKey), + + IdentifierExistenceScope.TenantPrimaryOnly => + candidates.Where(x => x.IsPrimary), + + IdentifierExistenceScope.TenantAny => + candidates, + + _ => candidates + }; + + var match = candidates.FirstOrDefault(); + + if (match is null) + return Task.FromResult(new IdentifierExistenceResult(false)); + + return Task.FromResult( + new IdentifierExistenceResult(true, match.UserKey, match.Id, match.IsPrimary)); + } + + public Task GetAsync(UserIdentifierType type, string normalizedValue, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var identifier = TenantValues() + .FirstOrDefault(x => + x.Type == type && + x.NormalizedValue == normalizedValue && + !x.IsDeleted); + + return Task.FromResult(identifier?.Snapshot()); + } + + public Task GetByIdAsync(Guid id, CancellationToken ct = default) + { + return GetAsync(id, ct); + } + + public Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var result = TenantValues() + .Where(x => x.UserKey == userKey) + .Where(x => !x.IsDeleted) + .OrderBy(x => x.CreatedAt) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } + + protected override void BeforeAdd(UserIdentifier entity) + { + if (!entity.IsPrimary) + return; + + foreach (var other in InternalValues().Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) + { + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); + } + } + + public override Task AddAsync(UserIdentifier entity, CancellationToken ct = default) + { + lock (_primaryLock) + { + return base.AddAsync(entity, ct); + } + } + + protected override void BeforeSave(UserIdentifier entity, UserIdentifier current, long expectedVersion) + { + if (!entity.IsPrimary) + return; + + foreach (var other in InternalValues().Where(x => + x.Tenant == entity.Tenant && + x.UserKey == entity.UserKey && + x.Type == entity.Type && + x.Id != entity.Id && + x.IsPrimary && + !x.IsDeleted)) + { + other.UnsetPrimary(entity.UpdatedAt ?? entity.CreatedAt); + } + } + + public override Task SaveAsync(UserIdentifier entity, long expectedVersion, CancellationToken ct = default) + { + lock (_primaryLock) + { + return base.SaveAsync(entity, expectedVersion, ct); + } + } + + public Task> QueryAsync(UserIdentifierQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (query.UserKey is null) + throw new UAuthIdentifierValidationException("userKey_required"); + + var normalized = query.Normalize(); + + var baseQuery = TenantValues() + .Where(x => x.UserKey == query.UserKey.Value); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); + + baseQuery = query.SortBy switch + { + nameof(UserIdentifier.Type) => query.Descending + ? baseQuery.OrderByDescending(x => x.Type) + : baseQuery.OrderBy(x => x.Type), + + nameof(UserIdentifier.CreatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserIdentifier.UpdatedAt) => query.Descending + ? baseQuery.OrderByDescending(x => x.UpdatedAt) + : baseQuery.OrderBy(x => x.UpdatedAt), + + nameof(UserIdentifier.Value) => query.Descending + ? baseQuery.OrderByDescending(x => x.Value) + : baseQuery.OrderBy(x => x.Value), + + nameof(UserIdentifier.NormalizedValue) => query.Descending + ? baseQuery.OrderByDescending(x => x.NormalizedValue) + : baseQuery.OrderBy(x => x.NormalizedValue), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + + } + + public Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = userKeys.ToHashSet(); + + var result = TenantValues() + .Where(x => set.Contains(x.UserKey)) + .Where(x => !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } + + public async Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var identifiers = TenantValues() + .Where(x => x.UserKey == userKey && !x.IsDeleted) + .ToList(); + + foreach (var identifier in identifiers) + { + await DeleteAsync(identifier.Id, identifier.Version, mode, deletedAt, ct); + } + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs new file mode 100644 index 00000000..828fcc51 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserIdentifierStoreFactory.cs @@ -0,0 +1,27 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserIdentifierStoreFactory : IUserIdentifierStoreFactory +{ + private readonly IServiceProvider _provider; + private readonly ConcurrentDictionary _stores = new(); + + public InMemoryUserIdentifierStoreFactory(IServiceProvider provider) + { + _provider = provider; + } + + public IUserIdentifierStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => + { + Console.WriteLine("New Store Added"); + var tenantContext = new TenantContext(tenant); + return ActivatorUtilities.CreateInstance(_provider, tenantContext); + }); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs new file mode 100644 index 00000000..4546acfc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStore.cs @@ -0,0 +1,65 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +public sealed class InMemoryUserLifecycleStore : InMemoryTenantVersionedStore, IUserLifecycleStore +{ + protected override UserLifecycleKey GetKey(UserLifecycle entity) + => new(entity.Tenant, entity.UserKey); + + public InMemoryUserLifecycleStore(TenantContext tenant) : base(tenant) + { + } + + public Task> QueryAsync(UserLifecycleQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + var baseQuery = TenantValues().AsQueryable(); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); + + if (query.Status != null) + baseQuery = baseQuery.Where(x => x.Status == query.Status); + + baseQuery = query.SortBy switch + { + nameof(UserLifecycle.Id) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Id) + : baseQuery.OrderBy(x => x.Id), + + nameof(UserLifecycle.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserLifecycle.Status) => + query.Descending + ? baseQuery.OrderByDescending(x => x.Status) + : baseQuery.OrderBy(x => x.Status), + + nameof(UserLifecycle.UserKey) => + query.Descending + ? baseQuery.OrderByDescending(x => x.UserKey.Value) + : baseQuery.OrderBy(x => x.UserKey.Value), + + nameof(UserLifecycle.DeletedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DeletedAt) + : baseQuery.OrderBy(x => x.DeletedAt), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + var items = baseQuery.Skip((normalized.PageNumber - 1) * normalized.PageSize).Take(normalized.PageSize).Select(x => x.Snapshot()).ToList().AsReadOnly(); + + return Task.FromResult(new PagedResult(items, totalCount, normalized.PageNumber, normalized.PageSize, query.SortBy, query.Descending)); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs new file mode 100644 index 00000000..f19359f7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserLifecycleStoreFactory.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserLifecycleStoreFactory : IUserLifecycleStoreFactory +{ + private readonly IServiceProvider _provider; + private readonly ConcurrentDictionary _stores = new(); + + public InMemoryUserLifecycleStoreFactory(IServiceProvider provider) + { + _provider = provider; + } + + public IUserLifecycleStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => + { + var tenantContext = new TenantContext(tenant); + return ActivatorUtilities.CreateInstance(_provider, tenantContext); + }); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs new file mode 100644 index 00000000..38195576 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStore.cs @@ -0,0 +1,87 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +public sealed class InMemoryUserProfileStore : InMemoryTenantVersionedStore, IUserProfileStore +{ + protected override UserProfileKey GetKey(UserProfile entity) + => new(entity.Tenant, entity.UserKey); + + public InMemoryUserProfileStore(TenantContext tenant) : base(tenant) + { + } + + public Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var normalized = query.Normalize(); + var baseQuery = TenantValues().AsQueryable(); + + if (!query.IncludeDeleted) + baseQuery = baseQuery.Where(x => !x.IsDeleted); + + baseQuery = query.SortBy switch + { + nameof(UserProfile.CreatedAt) => + query.Descending + ? baseQuery.OrderByDescending(x => x.CreatedAt) + : baseQuery.OrderBy(x => x.CreatedAt), + + nameof(UserProfile.DisplayName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.DisplayName) + : baseQuery.OrderBy(x => x.DisplayName), + + nameof(UserProfile.FirstName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.FirstName) + : baseQuery.OrderBy(x => x.FirstName), + + nameof(UserProfile.LastName) => + query.Descending + ? baseQuery.OrderByDescending(x => x.LastName) + : baseQuery.OrderBy(x => x.LastName), + + _ => baseQuery.OrderBy(x => x.CreatedAt) + }; + + var totalCount = baseQuery.Count(); + + var items = baseQuery + .Skip((normalized.PageNumber - 1) * normalized.PageSize) + .Take(normalized.PageSize) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult( + new PagedResult( + items, + totalCount, + normalized.PageNumber, + normalized.PageSize, + query.SortBy, + query.Descending)); + } + + public Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + var set = userKeys.ToHashSet(); + + var result = TenantValues() + .Where(x => set.Contains(x.UserKey)) + .Where(x => !x.IsDeleted) + .Select(x => x.Snapshot()) + .ToList() + .AsReadOnly(); + + return Task.FromResult>(result); + } +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs new file mode 100644 index 00000000..b6f49bd4 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.InMemory/Stores/InMemoryUserProfileStoreFactory.cs @@ -0,0 +1,26 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.DependencyInjection; +using System.Collections.Concurrent; + +namespace CodeBeam.UltimateAuth.Users.InMemory; + +internal sealed class InMemoryUserProfileStoreFactory : IUserProfileStoreFactory +{ + private readonly IServiceProvider _provider; + private readonly ConcurrentDictionary _stores = new(); + + public InMemoryUserProfileStoreFactory(IServiceProvider provider) + { + _provider = provider; + } + + public IUserProfileStore Create(TenantKey tenant) + { + return _stores.GetOrAdd(tenant, t => + { + var tenantContext = new TenantContext(t); + return ActivatorUtilities.CreateInstance(_provider, tenantContext); + }); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.InMemory/logo.png b/src/users/CodeBeam.UltimateAuth.Users.InMemory/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/users/CodeBeam.UltimateAuth.Users.InMemory/logo.png differ diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs new file mode 100644 index 00000000..ed166fcc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/AssemblyVisibility.cs @@ -0,0 +1,3 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj b/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj new file mode 100644 index 00000000..fca9576a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/CodeBeam.UltimateAuth.Users.Reference.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Users.Reference + + + Default reference implementation for the UltimateAuth Users module. + This package provides a ready-to-use implementation of the Users domain, including application services, endpoint handlers and default behaviors. + + + authentication;identity;users;reference;plugin;auth-framework + logo.png + README.md + + + + + + + + + + + + + + + diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs new file mode 100644 index 00000000..d308ff45 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserLifecycleQuery.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed record UserLifecycleQuery : PageRequest +{ + public bool IncludeDeleted { get; init; } + public UserStatus? Status { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs new file mode 100644 index 00000000..3c3835e6 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Contracts/UserProfileQuery.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed record UserProfileQuery : PageRequest +{ + public bool IncludeDeleted { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs new file mode 100644 index 00000000..1f00216e --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserIdentifier.cs @@ -0,0 +1,194 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserIdentifier : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot +{ + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } + public UserKey UserKey { get; init; } + + public UserIdentifierType Type { get; init; } // Email, Phone, Username + public string Value { get; private set; } = default!; + public string NormalizedValue { get; private set; } = default!; + + public bool IsPrimary { get; private set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? VerifiedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + + public bool IsDeleted => DeletedAt is not null; + public bool IsVerified => VerifiedAt is not null; + + public UserIdentifier Snapshot() + { + return new UserIdentifier + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + Type = Type, + Value = Value, + NormalizedValue = NormalizedValue, + IsPrimary = IsPrimary, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + VerifiedAt = VerifiedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserIdentifier Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + UserIdentifierType type, + string value, + string normalizedValue, + DateTimeOffset now, + bool isPrimary = false, + DateTimeOffset? verifiedAt = null) + { + return new UserIdentifier + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Type = type, + Value = value, + NormalizedValue = normalizedValue, + IsPrimary = isPrimary, + VerifiedAt = verifiedAt, + CreatedAt = now, + Version = 0 + }; + } + + public UserIdentifier ChangeValue(string newRawValue, string newNormalizedValue, DateTimeOffset now) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (NormalizedValue == newNormalizedValue) + throw new UAuthIdentifierConflictException("identifier_value_unchanged"); + + Value = newRawValue; + NormalizedValue = newNormalizedValue; + + VerifiedAt = null; + UpdatedAt = now; + + return this; + } + + public UserIdentifier MarkVerified(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (IsVerified) + throw new UAuthIdentifierConflictException("identifier_already_verified"); + + VerifiedAt = at; + UpdatedAt = at; + + return this; + } + + public UserIdentifier SetPrimary(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (IsPrimary) + return this; + + IsPrimary = true; + UpdatedAt = at; + + return this; + } + + public UserIdentifier UnsetPrimary(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (!IsPrimary) + throw new UAuthIdentifierConflictException("identifier_is_not_primary"); + + IsPrimary = false; + UpdatedAt = at; + + return this; + } + + public UserIdentifier MarkDeleted(DateTimeOffset at) + { + if (IsDeleted) + throw new UAuthIdentifierConflictException("identifier_already_deleted"); + + DeletedAt = at; + IsPrimary = false; + UpdatedAt = at; + + return this; + } + + public static UserIdentifier FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + UserIdentifierType type, + string value, + string normalizedValue, + bool isPrimary, + DateTimeOffset createdAt, + DateTimeOffset? verifiedAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new UserIdentifier + { + Id = id, + Tenant = tenant, + UserKey = userKey, + Type = type, + Value = value, + NormalizedValue = normalizedValue, + IsPrimary = isPrimary, + CreatedAt = createdAt, + VerifiedAt = verifiedAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + } + + public UserIdentifierInfo ToDto() + { + return new UserIdentifierInfo() + { + Id = Id, + Type = Type, + Value = Value, + NormalizedValue = NormalizedValue, + CreatedAt = CreatedAt, + IsPrimary = IsPrimary, + IsVerified = IsVerified, + VerifiedAt = VerifiedAt, + Version = Version + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs new file mode 100644 index 00000000..0d4d7d04 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycle.cs @@ -0,0 +1,121 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserLifecycle : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot +{ + private UserLifecycle() { } + + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } = default!; + public UserKey UserKey { get; private set; } = default!; + + public UserStatus Status { get; private set; } + + public long SecurityVersion { get; private set; } + + public DateTimeOffset CreatedAt { get; private set; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + public bool IsActive => !IsDeleted && Status == UserStatus.Active; + + public UserLifecycle Snapshot() + { + return new UserLifecycle + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + Status = Status, + SecurityVersion = SecurityVersion, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserLifecycle Create(TenantKey tenant, UserKey userKey, DateTimeOffset now, Guid? id = null) + { + return new UserLifecycle + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + Status = UserStatus.Active, + CreatedAt = now, + SecurityVersion = 0, + Version = 0 + }; + } + + public UserLifecycle MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + SecurityVersion++; + + return this; + } + + public UserLifecycle Activate(DateTimeOffset now) + { + if (Status == UserStatus.Active) + return this; + + Status = UserStatus.Active; + UpdatedAt = now; + return this; + } + + public UserLifecycle ChangeStatus(DateTimeOffset now, UserStatus newStatus) + { + if (Status == newStatus) + return this; + + Status = newStatus; + UpdatedAt = now; + return this; + } + + public UserLifecycle IncrementSecurityVersion() + { + SecurityVersion++; + return this; + } + + public static UserLifecycle FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + UserStatus status, + long securityVersion, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new UserLifecycle + { + Id = id, + Tenant = tenant, + UserKey = userKey, + Status = status, + SecurityVersion = securityVersion, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs new file mode 100644 index 00000000..8e1ad17b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserLifecycleKey.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public readonly record struct UserLifecycleKey( + TenantKey Tenant, + UserKey UserKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs new file mode 100644 index 00000000..9bc18905 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfile.cs @@ -0,0 +1,208 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +// TODO: Multi profile (e.g., public profiles, private profiles, profiles per application, etc. with ProfileKey) +public sealed class UserProfile : ITenantEntity, IVersionedEntity, ISoftDeletable, IEntitySnapshot +{ + private UserProfile() { } + + public Guid Id { get; private set; } + public TenantKey Tenant { get; private set; } + + public UserKey UserKey { get; init; } = default!; + + public string? FirstName { get; private set; } + public string? LastName { get; private set; } + public string? DisplayName { get; private set; } + + public DateOnly? BirthDate { get; private set; } + public string? Gender { get; private set; } + public string? Bio { get; private set; } + + public string? Language { get; private set; } + public string? TimeZone { get; private set; } + public string? Culture { get; private set; } + + public IReadOnlyDictionary? Metadata { get; private set; } + + public DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? UpdatedAt { get; private set; } + public DateTimeOffset? DeletedAt { get; private set; } + + public long Version { get; set; } + + public bool IsDeleted => DeletedAt != null; + public string EffectiveDisplayName => DisplayName ?? $"{FirstName} {LastName}"; + + public UserProfile Snapshot() + { + return new UserProfile + { + Id = Id, + Tenant = Tenant, + UserKey = UserKey, + FirstName = FirstName, + LastName = LastName, + DisplayName = DisplayName, + BirthDate = BirthDate, + Gender = Gender, + Bio = Bio, + Language = Language, + TimeZone = TimeZone, + Culture = Culture, + Metadata = Metadata, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + Version = Version + }; + } + + public static UserProfile Create( + Guid? id, + TenantKey tenant, + UserKey userKey, + DateTimeOffset createdAt, + string? firstName = null, + string? lastName = null, + string? displayName = null, + DateOnly? birthDate = null, + string? gender = null, + string? bio = null, + string? language = null, + string? timezone = null, + string? culture = null) + { + return new UserProfile + { + Id = id ?? Guid.NewGuid(), + Tenant = tenant, + UserKey = userKey, + FirstName = firstName, + LastName = lastName, + DisplayName = displayName, + BirthDate = birthDate, + Gender = gender, + Bio = bio, + Language = language, + TimeZone = timezone, + Culture = culture, + CreatedAt = createdAt, + UpdatedAt = null, + DeletedAt = null, + Metadata = null, + Version = 0 + }; + } + + public UserProfile UpdateName(string? firstName, string? lastName, string? displayName, DateTimeOffset now) + { + if (FirstName == firstName && + LastName == lastName && + DisplayName == displayName) + return this; + + FirstName = firstName; + LastName = lastName; + DisplayName = displayName; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdatePersonalInfo(DateOnly? birthDate, string? gender, string? bio, DateTimeOffset now) + { + if (BirthDate == birthDate && + Gender == gender && + Bio == bio) + return this; + + BirthDate = birthDate; + Gender = gender; + Bio = bio; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdateLocalization(string? language, string? timeZone, string? culture, DateTimeOffset now) + { + if (Language == language && + TimeZone == timeZone && + Culture == culture) + return this; + + Language = language; + TimeZone = timeZone; + Culture = culture; + UpdatedAt = now; + + return this; + } + + public UserProfile UpdateMetadata(IReadOnlyDictionary? metadata, DateTimeOffset now) + { + if (Metadata == metadata) + return this; + + Metadata = metadata; + UpdatedAt = now; + + return this; + } + + public UserProfile MarkDeleted(DateTimeOffset now) + { + if (IsDeleted) + return this; + + DeletedAt = now; + UpdatedAt = now; + + return this; + } + + public static UserProfile FromProjection( + Guid id, + TenantKey tenant, + UserKey userKey, + string? firstName, + string? lastName, + string? displayName, + DateOnly? birthDate, + string? gender, + string? bio, + string? language, + string? timeZone, + string? culture, + IReadOnlyDictionary? metadata, + DateTimeOffset createdAt, + DateTimeOffset? updatedAt, + DateTimeOffset? deletedAt, + long version) + { + return new UserProfile + { + Id = id, + Tenant = tenant, + UserKey = userKey, + FirstName = firstName, + LastName = lastName, + DisplayName = displayName, + BirthDate = birthDate, + Gender = gender, + Bio = bio, + Language = language, + TimeZone = timeZone, + Culture = culture, + Metadata = metadata, + CreatedAt = createdAt, + UpdatedAt = updatedAt, + DeletedAt = deletedAt, + Version = version + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs new file mode 100644 index 00000000..c197d94f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Domain/UserProfileKey.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public readonly record struct UserProfileKey( + TenantKey Tenant, + UserKey UserKey); diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs new file mode 100644 index 00000000..b48a4ecf --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Endpoints/UserEndpointHandler.cs @@ -0,0 +1,530 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public sealed class UserEndpointHandler : IUserEndpointHandler +{ + private readonly IAuthFlowContextAccessor _authFlow; + private readonly IAccessContextFactory _accessContextFactory; + private readonly IUserApplicationService _users; + + public UserEndpointHandler(IAuthFlowContextAccessor authFlow, IAccessContextFactory accessContextFactory, IUserApplicationService users) + { + _authFlow = authFlow; + _accessContextFactory = accessContextFactory; + _users = users; + } + + public async Task QueryUsersAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var query = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.QueryAdmin, + resource: "users"); + + var result = await _users.QueryUsersAsync(accessContext, query, ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task CreateAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.CreateAnonymous, + resource: "users"); + + var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task CreateAdminAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.CreateAdmin, + resource: "users"); + + var result = await _users.CreateUserAsync(accessContext, request, ctx.RequestAborted); + + return result.Succeeded + ? Results.Ok(result) + : Results.BadRequest(result); + } + + public async Task ChangeStatusSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.ChangeStatusSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task ChangeStatusAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.ChangeStatusAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.ChangeUserStatusAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task GetMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.GetSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + var profile = await _users.GetMeAsync(accessContext, ctx.RequestAborted); + return Results.Ok(profile); + } + + public async Task GetUserAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var profile = await _users.GetUserProfileAsync(accessContext, ctx.RequestAborted); + return Results.Ok(profile); + } + + public async Task UpdateMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.UpdateSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.UpdateUserProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UpdateUserAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserProfiles.UpdateAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.UpdateUserProfileAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteMeAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.DeleteSelf, + resource: "users", + resourceId: flow?.UserKey?.Value); + + await _users.DeleteMeAsync(accessContext, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.Users.DeleteAdmin, + resource: "users", + resourceId: userKey.Value, + attributes: new Dictionary + { + ["deleteMode"] = request.Mode + }); + + await _users.DeleteUserAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task GetMyIdentifiersAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new UserIdentifierQuery(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task GetUserIdentifiersAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted) ?? new UserIdentifierQuery(); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var result = await _users.GetIdentifiersByUserAsync(accessContext, request, ctx.RequestAborted); + + return Results.Ok(result); + } + + public async Task IdentifierExistsSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + var exists = await _users.UserIdentifierExistsAsync( + accessContext, + request.Type, + request.Value, + IdentifierExistenceScope.WithinUser, + ctx.RequestAborted); + + return Results.Ok(new IdentifierExistsResponse + { + Exists = exists + }); + } + + public async Task IdentifierExistsAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.GetAdmin, + resource: "users", + resourceId: userKey.Value); + + var exists = await _users.UserIdentifierExistsAsync( + accessContext, + request.Type, + request.Value, + IdentifierExistenceScope.TenantAny, + ctx.RequestAborted); + + return Results.Ok(new IdentifierExistsResponse + { + Exists = exists + }); + } + + public async Task AddUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.AddSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.AddUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task AddUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.AddAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.AddUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UpdateUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UpdateSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.UpdateUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UpdateUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UpdateAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.UpdateUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task SetPrimaryUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.SetPrimarySelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.SetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task SetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.SetPrimaryAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.SetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UnsetPrimaryUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UnsetPrimarySelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.UnsetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task UnsetPrimaryUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.UnsetPrimaryAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.UnsetPrimaryUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task VerifyUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.VerifySelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.VerifyUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task VerifyUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.VerifyAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.VerifyUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteUserIdentifierSelfAsync(HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.DeleteSelf, + resource: "users", + resourceId: flow.UserKey!.Value); + + await _users.DeleteUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } + + public async Task DeleteUserIdentifierAdminAsync(UserKey userKey, HttpContext ctx) + { + var flow = _authFlow.Current; + if (!flow.IsAuthenticated) + return Results.Unauthorized(); + + var request = await ctx.ReadJsonAsync(ctx.RequestAborted); + + var accessContext = await _accessContextFactory.CreateAsync( + authFlow: flow, + action: UAuthActions.UserIdentifiers.DeleteAdmin, + resource: "users", + resourceId: userKey.Value); + + await _users.DeleteUserIdentifierAsync(accessContext, request, ctx.RequestAborted); + return Results.Ok(); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs new file mode 100644 index 00000000..2cfabc66 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Extensions/ServiceCollectonExtensions.cs @@ -0,0 +1,28 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Endpoints; +using CodeBeam.UltimateAuth.Core.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +namespace CodeBeam.UltimateAuth.Users.Reference.Extensions; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddUltimateAuthUsersReference(this IServiceCollection services) + { + services.PostConfigure(_ => + { + // Marker only โ€“ runtime validation happens via DI resolution + }); + + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.AddScoped(); + + return services; + } + + private sealed class UsersReferenceMarker; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs new file mode 100644 index 00000000..9a1350e0 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/LoginIdentifierResolver.cs @@ -0,0 +1,145 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Users.Reference; +public sealed class LoginIdentifierResolver : ILoginIdentifierResolver +{ + private readonly IUserIdentifierStoreFactory _storeFactory; + private readonly IIdentifierNormalizer _normalizer; + private readonly IEnumerable _customResolvers; + private readonly UAuthLoginIdentifierOptions _options; + + public LoginIdentifierResolver( + IUserIdentifierStoreFactory storeFactory, + IIdentifierNormalizer normalizer, + IEnumerable customResolvers, + IOptions options) + { + _storeFactory = storeFactory; + _normalizer = normalizer; + _customResolvers = customResolvers; + _options = options.Value.LoginIdentifiers; + } + + public async Task ResolveAsync(TenantKey tenant, string identifier, CancellationToken ct = default) + { + ct.ThrowIfCancellationRequested(); + + if (string.IsNullOrWhiteSpace(identifier)) + return null; + + var raw = identifier; + + var builtInType = DetectBuiltInType(identifier); + + var normalizedResult = _normalizer.Normalize(builtInType, identifier); + + if (!normalizedResult.IsValid) + return null; + + var normalized = normalizedResult.Normalized; + + + if (_options.EnableCustomResolvers && _options.CustomResolversFirst) + { + var custom = await TryCustomAsync(tenant, normalized, ct); + if (custom is not null) + return custom; + } + + if (!_options.AllowedTypes.Contains(builtInType)) + { + if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) + return await TryCustomAsync(tenant, normalized, ct); + + return null; + } + + var store = _storeFactory.Create(tenant); + var found = await store.GetAsync(builtInType, normalized, ct); + if (found is null || !found.IsPrimary) + { + if (_options.EnableCustomResolvers && !_options.CustomResolversFirst) + return await TryCustomAsync(tenant, normalized, ct); + + return new LoginIdentifierResolution + { + Tenant = tenant, + RawIdentifier = raw, + NormalizedIdentifier = normalized, + BuiltInType = builtInType, + UserKey = null, + IsVerified = false + }; + } + + if (builtInType == UserIdentifierType.Email && _options.RequireVerificationForEmail && !found.IsVerified) + return new LoginIdentifierResolution + { + Tenant = tenant, + RawIdentifier = raw, + NormalizedIdentifier = normalized, + BuiltInType = builtInType, + UserKey = null, + IsVerified = false + }; + + if (builtInType == UserIdentifierType.Phone && _options.RequireVerificationForPhone && !found.IsVerified) + return new LoginIdentifierResolution + { + Tenant = tenant, + RawIdentifier = raw, + NormalizedIdentifier = normalized, + BuiltInType = builtInType, + UserKey = null, + IsVerified = false + }; + + return new LoginIdentifierResolution + { + Tenant = tenant, + RawIdentifier = raw, + NormalizedIdentifier = normalized, + BuiltInType = builtInType, + UserKey = found.UserKey, + IsVerified = found.IsVerified + }; + } + + private async Task TryCustomAsync(TenantKey tenant, string normalizedIdentifier, CancellationToken ct) + { + foreach (var r in _customResolvers) + { + if (!r.CanResolve(normalizedIdentifier)) + continue; + + var result = await r.ResolveAsync(tenant, normalizedIdentifier, ct); + if (result is not null) + return result; + } + + return null; + } + + private static UserIdentifierType DetectBuiltInType(string normalized) + { + if (normalized.Contains('@', StringComparison.Ordinal)) + return UserIdentifierType.Email; + + var digits = 0; + foreach (var ch in normalized) + { + if (char.IsDigit(ch)) digits++; + else if (ch is '+' or '-' or ' ' or '(' or ')') { } + else return UserIdentifierType.Username; + } + + if (digits >= 7) + return UserIdentifierType.Phone; + + return UserIdentifierType.Username; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs new file mode 100644 index 00000000..1e096928 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/PrimaryUserIdentifierProvider.cs @@ -0,0 +1,32 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class PrimaryUserIdentifierProvider : IPrimaryUserIdentifierProvider +{ + private readonly IUserIdentifierStoreFactory _storeFactory; + + public PrimaryUserIdentifierProvider(IUserIdentifierStoreFactory storeFactory) + { + _storeFactory = storeFactory; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenant); + var identifiers = await store.GetByUserAsync(userKey, ct); + var primary = identifiers.Where(x => x.IsPrimary).ToList(); + + if (primary.Count == 0) + return null; + + return new PrimaryUserIdentifiers + { + UserName = primary.FirstOrDefault(x => x.Type == UserIdentifierType.Username)?.Value, + Email = primary.FirstOrDefault(x => x.Type == UserIdentifierType.Email)?.Value, + Phone = primary.FirstOrDefault(x => x.Type == UserIdentifierType.Phone)?.Value + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs new file mode 100644 index 00000000..34a9d8cb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserLifecycleSnaphotProvider.cs @@ -0,0 +1,30 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserLifecycleSnapshotProvider : IUserLifecycleSnapshotProvider +{ + private readonly IUserLifecycleStoreFactory _storeFactory; + + public UserLifecycleSnapshotProvider(IUserLifecycleStoreFactory storeFactory) + { + _storeFactory = storeFactory; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenant); + var profile = await store.GetAsync(new UserLifecycleKey(tenant, userKey), ct); + + if (profile is null || profile.IsDeleted) + return null; + + return new UserLifecycleSnapshot + { + UserKey = profile.UserKey, + Status = profile.Status + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs new file mode 100644 index 00000000..72cbe134 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Infrastructure/UserProfileSnapshotProvider.cs @@ -0,0 +1,31 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserProfileSnapshotProvider : IUserProfileSnapshotProvider +{ + private readonly IUserProfileStoreFactory _storeFactory; + + public UserProfileSnapshotProvider(IUserProfileStoreFactory storeFactory) + { + _storeFactory = storeFactory; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var store = _storeFactory.Create(tenant); + var profile = await store.GetAsync(new UserProfileKey(tenant, userKey), ct); + + if (profile is null || profile.IsDeleted) + return null; + + return new UserProfileSnapshot + { + DisplayName = profile.DisplayName, + Language = profile.Language, + TimeZone = profile.TimeZone + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs new file mode 100644 index 00000000..ff65fed1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserIdentifierMapper.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public static class UserIdentifierMapper +{ + public static UserIdentifierInfo ToDto(UserIdentifier record) + => new() + { + Id = record.Id, + Type = record.Type, + Value = record.Value, + NormalizedValue = record.NormalizedValue, + IsPrimary = record.IsPrimary, + IsVerified = record.IsVerified, + CreatedAt = record.CreatedAt, + VerifiedAt = record.VerifiedAt, + Version = record.Version + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs new file mode 100644 index 00000000..e7a95f69 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Mapping/UserProfileMapper.cs @@ -0,0 +1,23 @@ +๏ปฟusing CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal static class UserProfileMapper +{ + public static UserView ToDto(UserProfile profile) + => new() + { + UserKey = profile.UserKey, + FirstName = profile.FirstName, + LastName = profile.LastName, + DisplayName = profile.DisplayName, + Bio = profile.Bio, + BirthDate = profile.BirthDate, + CreatedAt = profile.CreatedAt, + Gender = profile.Gender, + Culture = profile.Culture, + Language = profile.Language, + TimeZone = profile.TimeZone, + Metadata = profile.Metadata + }; +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/README.md b/src/users/CodeBeam.UltimateAuth.Users.Reference/README.md new file mode 100644 index 00000000..2da828af --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/README.md @@ -0,0 +1,40 @@ +๏ปฟ# UltimateAuth Users Reference + +Default reference implementation for the UltimateAuth Users module. + +## Purpose + +This package provides a ready-to-use implementation of the Users domain, including: + +- User management application services +- Default endpoint handlers +- Built-in behaviors and flows + +It is intended as a starting point for most applications. + +## Usage + +Once installed, the Users module is automatically wired into the UltimateAuth server. + +No additional setup is required. + +## โš ๏ธ Important + +This is a **reference implementation**, not a strict requirement. + +You are free to: + +- Replace it partially or completely +- Implement your own Users module +- Use only the Users.Contracts package + +## Architecture Notes + +This package currently depends on the server runtime. + +In future versions, it will evolve into a fully decoupled plugin-based architecture. + +## When to NOT use this package + +- When building a fully custom Users domain +- When integrating with an external identity system \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs new file mode 100644 index 00000000..5c9157f1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/IUserApplicationService.cs @@ -0,0 +1,38 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserApplicationService +{ + Task GetMeAsync(AccessContext context, CancellationToken ct = default); + Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default); + + Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default); + Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); + + Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default); + + Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default); + + Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default); + + Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default); + + Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default); + + Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default); + + Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default); + + Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default); + + Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default); + + Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default); + + Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default); + + Task DeleteMeAsync(AccessContext context, CancellationToken ct = default); + Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs new file mode 100644 index 00000000..aa354974 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Services/UserApplicationService.cs @@ -0,0 +1,862 @@ +๏ปฟ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.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserApplicationService : IUserApplicationService +{ + private readonly IAccessOrchestrator _accessOrchestrator; + private readonly IUserLifecycleStoreFactory _lifecycleStoreFactory; + private readonly IUserIdentifierStoreFactory _identifierStoreFactory; + private readonly IUserProfileStoreFactory _profileStoreFactory; + private readonly IUserCreateValidator _userCreateValidator; + private readonly IIdentifierValidator _identifierValidator; + private readonly IEnumerable _integrations; + private readonly IIdentifierNormalizer _identifierNormalizer; + private readonly ISessionStoreFactory _sessionStoreFactory; + private readonly UAuthServerOptions _options; + private readonly IClock _clock; + + public UserApplicationService( + IAccessOrchestrator accessOrchestrator, + IUserLifecycleStoreFactory lifecycleStoreFactory, + IUserIdentifierStoreFactory identifierStoreFactory, + IUserProfileStoreFactory profileStoreFactory, + IUserCreateValidator userCreateValidator, + IIdentifierValidator identifierValidator, + IEnumerable integrations, + IIdentifierNormalizer identifierNormalizer, + ISessionStoreFactory sessionStoreFactory, + IOptions options, + IClock clock) + { + _accessOrchestrator = accessOrchestrator; + _lifecycleStoreFactory = lifecycleStoreFactory; + _identifierStoreFactory = identifierStoreFactory; + _profileStoreFactory = profileStoreFactory; + _userCreateValidator = userCreateValidator; + _identifierValidator = identifierValidator; + _integrations = integrations; + _identifierNormalizer = identifierNormalizer; + _sessionStoreFactory = sessionStoreFactory; + _options = options.Value; + _clock = clock; + } + + #region User Lifecycle + + public async Task CreateUserAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var validationResult = await _userCreateValidator.ValidateAsync(context, request, innerCt); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + + var now = _clock.UtcNow; + var userKey = UserKey.New(); + + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + await lifecycleStore.AddAsync(UserLifecycle.Create(context.ResourceTenant, userKey, now), innerCt); + + var profileStore = _profileStoreFactory.Create(context.ResourceTenant); + await profileStore.AddAsync( + UserProfile.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + now, + firstName: request.FirstName, + lastName: request.LastName, + displayName: request.DisplayName ?? request.UserName ?? request.Email ?? request.Phone, + birthDate: request.BirthDate, + gender: request.Gender, + bio: request.Bio, + language: request.Language, + timezone: request.TimeZone, + culture: request.Culture), innerCt); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + if (!string.IsNullOrWhiteSpace(request.UserName)) + { + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Username, + request.UserName, + _identifierNormalizer.Normalize(UserIdentifierType.Username, request.UserName).Normalized, + now, + true, + request.UserNameVerified ? now : null), innerCt); + } + + if (!string.IsNullOrWhiteSpace(request.Email)) + { + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Email, + request.Email, + _identifierNormalizer.Normalize(UserIdentifierType.Email, request.Email).Normalized, + now, + true, + request.EmailVerified ? now : null), innerCt); + } + + if (!string.IsNullOrWhiteSpace(request.Phone)) + { + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + UserIdentifierType.Phone, + request.Phone, + _identifierNormalizer.Normalize(UserIdentifierType.Phone, request.Phone).Normalized, + now, + true, + request.PhoneVerified ? now : null), innerCt); + } + + foreach (var integration in _integrations) + { + // Credential creation handle on here + await integration.OnUserCreatedAsync(context.ResourceTenant, userKey, request, innerCt); + } + + return UserCreateResult.Success(userKey); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task ChangeUserStatusAsync(AccessContext context, object request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var newStatus = request switch + { + ChangeUserStatusSelfRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), + ChangeUserStatusAdminRequest r => UserStatusMapper.ToUserStatus(r.NewStatus), + _ => throw new InvalidOperationException("invalid_request") + }; + + var targetUserKey = context.GetTargetUserKey(); + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var current = await lifecycleStore.GetAsync(userLifecycleKey, innerCt); + var now = _clock.UtcNow; + + if (current is null) + throw new UAuthNotFoundException("user_not_found"); + + if (context.IsSelfAction && !IsSelfTransitionAllowed(current.Status, newStatus)) + throw new UAuthConflictException("self_transition_not_allowed"); + + if (!context.IsSelfAction) + { + if (newStatus is UserStatus.SelfSuspended) + throw new UAuthConflictException("admin_cannot_set_self_status"); + } + var newEntity = current.ChangeStatus(now, newStatus); + await lifecycleStore.SaveAsync(newEntity, current.Version, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var lifecycleKey = new UserLifecycleKey(context.ResourceTenant, userKey); + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var lifecycle = await lifecycleStore.GetAsync(lifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + var profileStore = _profileStoreFactory.Create(context.ResourceTenant); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var profileKey = new UserProfileKey(context.ResourceTenant, userKey); + var profile = await profileStore.GetAsync(profileKey, innerCt); + + await lifecycleStore.DeleteAsync(lifecycleKey, lifecycle.Version, DeleteMode.Soft, now, innerCt); + await identifierStore.DeleteByUserAsync(userKey, DeleteMode.Soft, now, innerCt); + + if (profile is not null) + { + await profileStore.DeleteAsync(profileKey, profile.Version, DeleteMode.Soft, now, innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenant, userKey, DeleteMode.Soft, innerCt); + } + + var sessionStore = _sessionStoreFactory.Create(context.ResourceTenant); + await sessionStore.RevokeAllChainsAsync(userKey, now, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserAsync(AccessContext context, DeleteUserRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + var userLifecycleKey = new UserLifecycleKey(context.ResourceTenant, targetUserKey); + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var lifecycle = await lifecycleStore.GetAsync(userLifecycleKey, innerCt); + + if (lifecycle is null) + throw new UAuthNotFoundException(); + + var profileStore = _profileStoreFactory.Create(context.ResourceTenant); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var profileKey = new UserProfileKey(context.ResourceTenant, targetUserKey); + var profile = await profileStore.GetAsync(profileKey, innerCt); + await lifecycleStore.DeleteAsync(userLifecycleKey, lifecycle.Version, request.Mode, now, innerCt); + await identifierStore.DeleteByUserAsync(targetUserKey, request.Mode, now, innerCt); + + if (profile is not null) + { + await profileStore.DeleteAsync(profileKey, profile.Version, request.Mode, now, innerCt); + } + + foreach (var integration in _integrations) + { + await integration.OnUserDeletedAsync(context.ResourceTenant, targetUserKey, request.Mode, innerCt); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + #endregion + + + #region User Profile + + public async Task GetMeAsync(AccessContext context, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + if (context.ActorUserKey is null) + throw new UnauthorizedAccessException(); + + return await BuildUserViewAsync(context.ResourceTenant, context.ActorUserKey.Value, innerCt); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetUserProfileAsync(AccessContext context, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + return await BuildUserViewAsync(context.ResourceTenant, targetUserKey, innerCt); + + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserProfileAsync(AccessContext context, UpdateProfileRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var tenant = context.ResourceTenant; + var userKey = context.GetTargetUserKey(); + var now = _clock.UtcNow; + + var key = new UserProfileKey(tenant, userKey); + var profileStore = _profileStoreFactory.Create(tenant); + var profile = await profileStore.GetAsync(key, innerCt); + + if (profile is null) + throw new UAuthNotFoundException(); + + var expectedVersion = profile.Version; + + profile + .UpdateName(request.FirstName, request.LastName, request.DisplayName, now) + .UpdatePersonalInfo(request.BirthDate, request.Gender, request.Bio, now) + .UpdateLocalization(request.Language, request.TimeZone, request.Culture, now) + .UpdateMetadata(request.Metadata, now); + + await profileStore.SaveAsync(profile, expectedVersion, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + #endregion + + + #region Identifiers + + public async Task> GetIdentifiersByUserAsync(AccessContext context, UserIdentifierQuery query, CancellationToken ct = default) + { + var command = new AccessCommand>(async innerCt => + { + var targetUserKey = context.GetTargetUserKey(); + + query ??= new UserIdentifierQuery(); + query = query with + { + UserKey = targetUserKey + }; + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var result = await identifierStore.QueryAsync(query, innerCt); + var dtoItems = result.Items.Select(UserIdentifierMapper.ToDto).ToList().AsReadOnly(); + + return new PagedResult( + dtoItems, + result.TotalCount, + result.PageNumber, + result.PageSize, + result.SortBy, + result.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task GetIdentifierAsync(AccessContext context, UserIdentifierType type, string value, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return null; + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetAsync(type, normalized.Normalized, innerCt); + return identifier is null ? null : UserIdentifierMapper.ToDto(identifier); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UserIdentifierExistsAsync(AccessContext context, UserIdentifierType type, string value, IdentifierExistenceScope scope = IdentifierExistenceScope.TenantPrimaryOnly, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var normalized = _identifierNormalizer.Normalize(type, value); + if (!normalized.IsValid) + return false; + + UserKey? userKey = scope == IdentifierExistenceScope.WithinUser ? context.GetTargetUserKey() : null; + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var result = await identifierStore.ExistsAsync(new IdentifierExistenceQuery(type, normalized.Normalized, scope, userKey), innerCt); + return result.Exists; + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task AddUserIdentifierAsync(AccessContext context, AddUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + var validationDto = new UserIdentifierInfo() { Type = request.Type, Value = request.Value }; + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + + EnsureOverrideAllowed(context); + var userKey = context.GetTargetUserKey(); + + var normalized = _identifierNormalizer.Normalize(request.Type, request.Value); + if (!normalized.IsValid) + throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var existing = await identifierStore.GetByUserAsync(userKey, innerCt); + EnsureMultipleIdentifierAllowed(request.Type, existing); + + var userScopeResult = await identifierStore.ExistsAsync( + new IdentifierExistenceQuery(request.Type, normalized.Normalized, IdentifierExistenceScope.WithinUser, UserKey: userKey), innerCt); + + if (userScopeResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); + + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || + (request.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(request.Type)); + + if (mustBeUnique) + { + var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers + ? IdentifierExistenceScope.TenantAny + : IdentifierExistenceScope.TenantPrimaryOnly; + + var globalResult = await identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + request.Type, + normalized.Normalized, + scope), + innerCt); + + if (globalResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + + if (request.IsPrimary) + { + // new identifiers are not verified by default, so we check against the requirement even if the request doesn't explicitly set it to true. + // This prevents adding a primary identifier that doesn't meet verification requirements. + EnsureVerificationRequirements(request.Type, isVerified: false); + } + + await identifierStore.AddAsync( + UserIdentifier.Create( + Guid.NewGuid(), + context.ResourceTenant, + userKey, + request.Type, + request.Value, + normalized.Normalized, + _clock.UtcNow, + request.IsPrimary), innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UpdateUserIdentifierAsync(AccessContext context, UpdateUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + EnsureOverrideAllowed(context); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); + + if (identifier is null || identifier.IsDeleted) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (identifier.Type == UserIdentifierType.Username && !_options.Identifiers.AllowUsernameChange) + { + throw new UAuthIdentifierValidationException("username_change_not_allowed"); + } + + var validationDto = identifier.ToDto(); + var validationResult = await _identifierValidator.ValidateAsync(context, validationDto, innerCt); + if (validationResult.IsValid != true) + { + throw new UAuthValidationException(string.Join(", ", validationResult.Errors)); + } + + var normalized = _identifierNormalizer.Normalize(identifier.Type, request.NewValue); + if (!normalized.IsValid) + throw new UAuthIdentifierValidationException(normalized.ErrorCode ?? "identifier_invalid"); + + if (string.Equals(identifier.NormalizedValue, normalized.Normalized, StringComparison.Ordinal)) + throw new UAuthIdentifierValidationException("identifier_value_unchanged"); + + var withinUserResult = await identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + normalized.Normalized, + IdentifierExistenceScope.WithinUser, + UserKey: identifier.UserKey, + ExcludeIdentifierId: identifier.Id), + innerCt); + + if (withinUserResult.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists_for_user"); + + var mustBeUnique = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers || + (identifier.IsPrimary && _options.LoginIdentifiers.AllowedTypes.Contains(identifier.Type)); + + if (mustBeUnique) + { + var scope = _options.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers + ? IdentifierExistenceScope.TenantAny + : IdentifierExistenceScope.TenantPrimaryOnly; + + var result = await identifierStore.ExistsAsync( + new IdentifierExistenceQuery( + identifier.Type, + normalized.Normalized, + scope, + ExcludeIdentifierId: identifier.Id), + innerCt); + + if (result.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + } + + var expectedVersion = identifier.Version; + identifier.ChangeValue(request.NewValue, normalized.Normalized, _clock.UtcNow); + + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task SetPrimaryUserIdentifierAsync(AccessContext context, SetPrimaryUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + EnsureOverrideAllowed(context); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_already_primary"); + + EnsureVerificationRequirements(identifier.Type, identifier.IsVerified); + + var result = await identifierStore.ExistsAsync( + new IdentifierExistenceQuery(identifier.Type, identifier.NormalizedValue, IdentifierExistenceScope.TenantPrimaryOnly, ExcludeIdentifierId: identifier.Id), innerCt); + + if (result.Exists) + throw new UAuthIdentifierConflictException("identifier_already_exists"); + + var expectedVersion = identifier.Version; + identifier.SetPrimary(_clock.UtcNow); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task UnsetPrimaryUserIdentifierAsync(AccessContext context, UnsetPrimaryUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + EnsureOverrideAllowed(context); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + if (!identifier.IsPrimary) + throw new UAuthIdentifierValidationException("identifier_already_not_primary"); + + var userIdentifiers = + await identifierStore.GetByUserAsync(identifier.UserKey, innerCt); + + var activeLoginPrimaries = userIdentifiers + .Where(i => + !i.IsDeleted && + i.IsPrimary && + _options.LoginIdentifiers.AllowedTypes.Contains(i.Type)) + .ToList(); + + if (activeLoginPrimaries.Count == 1 && + activeLoginPrimaries[0].Id == identifier.Id) + { + throw new UAuthIdentifierConflictException("cannot_unset_last_login_identifier"); + } + + var expectedVersion = identifier.Version; + identifier.UnsetPrimary(_clock.UtcNow); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task VerifyUserIdentifierAsync(AccessContext context, VerifyUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + EnsureOverrideAllowed(context); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + var expectedVersion = identifier.Version; + identifier.MarkVerified(_clock.UtcNow); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + public async Task DeleteUserIdentifierAsync(AccessContext context, DeleteUserIdentifierRequest request, CancellationToken ct = default) + { + var command = new AccessCommand(async innerCt => + { + EnsureOverrideAllowed(context); + + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var identifier = await identifierStore.GetByIdAsync(request.Id, innerCt); + if (identifier is null) + throw new UAuthIdentifierNotFoundException("identifier_not_found"); + + var identifiers = await identifierStore.GetByUserAsync(identifier.UserKey, innerCt); + var loginIdentifiers = identifiers.Where(i => !i.IsDeleted && IsLoginIdentifier(i.Type)).ToList(); + + if (identifier.IsPrimary) + throw new UAuthIdentifierValidationException("cannot_delete_primary_identifier"); + + if (_options.Identifiers.RequireUsernameIdentifier && identifier.Type == UserIdentifierType.Username) + { + var activeUsernames = identifiers + .Where(i => !i.IsDeleted && i.Type == UserIdentifierType.Username) + .ToList(); + + if (activeUsernames.Count == 1) + throw new UAuthIdentifierConflictException("cannot_delete_last_username_identifier"); + } + + if (IsLoginIdentifier(identifier.Type) && loginIdentifiers.Count == 1) + throw new UAuthIdentifierConflictException("cannot_delete_last_login_identifier"); + + var expectedVersion = identifier.Version; + + if (request.Mode == DeleteMode.Hard) + { + await identifierStore.DeleteAsync(identifier.Id, expectedVersion, DeleteMode.Hard, _clock.UtcNow, innerCt); + } + else + { + identifier.MarkDeleted(_clock.UtcNow); + await identifierStore.SaveAsync(identifier, expectedVersion, innerCt); + } + }); + + await _accessOrchestrator.ExecuteAsync(context, command, ct); + } + + #endregion + + + #region Helpers + + private async Task BuildUserViewAsync(TenantKey tenant, UserKey userKey, CancellationToken ct) + { + var lifecycleStore = _lifecycleStoreFactory.Create(tenant); + var identifierStore = _identifierStoreFactory.Create(tenant); + var profileStore = _profileStoreFactory.Create(tenant); + var lifecycle = await lifecycleStore.GetAsync(new UserLifecycleKey(tenant, userKey)); + var profile = await profileStore.GetAsync(new UserProfileKey(tenant, userKey), ct); + + if (lifecycle is null || lifecycle.IsDeleted) + throw new UAuthNotFoundException("user_not_found"); + + if (profile is null || profile.IsDeleted) + throw new UAuthNotFoundException("user_profile_not_found"); + + var identifiers = await identifierStore.GetByUserAsync(userKey, ct); + + var username = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Username && x.IsPrimary); + var primaryEmail = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + var primaryPhone = identifiers.FirstOrDefault(x => x.Type == UserIdentifierType.Phone && x.IsPrimary); + + var dto = UserProfileMapper.ToDto(profile); + + return dto with + { + UserName = username?.Value, + PrimaryEmail = primaryEmail?.Value, + PrimaryPhone = primaryPhone?.Value, + EmailVerified = primaryEmail?.IsVerified ?? false, + PhoneVerified = primaryPhone?.IsVerified ?? false, + Status = lifecycle.Status + }; + } + + private void EnsureMultipleIdentifierAllowed(UserIdentifierType type, IReadOnlyList existing) + { + bool hasSameType = existing.Any(i => !i.IsDeleted && i.Type == type); + + if (!hasSameType) + return; + + if (type == UserIdentifierType.Username && !_options.Identifiers.AllowMultipleUsernames) + throw new UAuthValidationException("multiple_usernames_not_allowed"); + + if (type == UserIdentifierType.Email && !_options.Identifiers.AllowMultipleEmail) + throw new UAuthValidationException("multiple_emails_not_allowed"); + + if (type == UserIdentifierType.Phone && !_options.Identifiers.AllowMultiplePhone) + throw new UAuthValidationException("multiple_phones_not_allowed"); + } + + private void EnsureVerificationRequirements(UserIdentifierType type, bool isVerified) + { + if (type == UserIdentifierType.Email && _options.Identifiers.RequireEmailVerification && !isVerified) + { + throw new UAuthValidationException("email_verification_required"); + } + + if (type == UserIdentifierType.Phone && _options.Identifiers.RequirePhoneVerification && !isVerified) + { + throw new UAuthValidationException("phone_verification_required"); + } + } + + private void EnsureOverrideAllowed(AccessContext context) + { + if (context.IsSelfAction && !_options.Identifiers.AllowUserOverride) + throw new UAuthConflictException("user_override_not_allowed"); + + if (!context.IsSelfAction && !_options.Identifiers.AllowAdminOverride) + throw new UAuthConflictException("admin_override_not_allowed"); + } + + private static bool IsSelfTransitionAllowed(UserStatus from, UserStatus to) + => (from, to) switch + { + (UserStatus.Active, UserStatus.SelfSuspended) => true, + (UserStatus.SelfSuspended, UserStatus.Active) => true, + _ => false + }; + + private static bool IsLoginIdentifier(UserIdentifierType type) + => type is + UserIdentifierType.Username or + UserIdentifierType.Email or + UserIdentifierType.Phone; + + #endregion + + public async Task> QueryUsersAsync(AccessContext context, UserQuery query, CancellationToken ct = default) + { + var command = new AccessCommand>(async innerCt => + { + query ??= new UserQuery(); + + var lifecycleQuery = new UserLifecycleQuery + { + PageNumber = 1, + PageSize = int.MaxValue, + Status = query.Status, + IncludeDeleted = query.IncludeDeleted + }; + + var lifecycleStore = _lifecycleStoreFactory.Create(context.ResourceTenant); + var lifecycleResult = await lifecycleStore.QueryAsync(lifecycleQuery, innerCt); + var lifecycles = lifecycleResult.Items; + + if (lifecycles.Count == 0) + { + return new PagedResult( + Array.Empty(), + 0, + query.PageNumber, + query.PageSize, + query.SortBy, + query.Descending); + } + + var userKeys = lifecycles.Select(x => x.UserKey).ToList(); + var profileStore = _profileStoreFactory.Create(context.ResourceTenant); + var identifierStore = _identifierStoreFactory.Create(context.ResourceTenant); + var profiles = await profileStore.GetByUsersAsync(userKeys, innerCt); + var identifiers = await identifierStore.GetByUsersAsync(userKeys, innerCt); + var profileMap = profiles.ToDictionary(x => x.UserKey); + var identifierGroups = identifiers.GroupBy(x => x.UserKey).ToDictionary(x => x.Key, x => x.ToList()); + + var summaries = new List(); + + foreach (var lifecycle in lifecycles) + { + profileMap.TryGetValue(lifecycle.UserKey, out var profile); + + identifierGroups.TryGetValue(lifecycle.UserKey, out var ids); + + var username = ids?.FirstOrDefault(x => + x.Type == UserIdentifierType.Username && + x.IsPrimary); + + var email = ids?.FirstOrDefault(x => + x.Type == UserIdentifierType.Email && + x.IsPrimary); + + summaries.Add(new UserSummary + { + UserKey = lifecycle.UserKey, + DisplayName = profile?.DisplayName, + UserName = username?.Value, + PrimaryEmail = email?.Value, + Status = lifecycle.Status, + CreatedAt = lifecycle.CreatedAt + }); + } + + // SEARCH + if (!string.IsNullOrWhiteSpace(query.Search)) + { + var search = query.Search.Trim().ToLowerInvariant(); + + summaries = summaries + .Where(x => + (x.DisplayName?.ToLowerInvariant().Contains(search) ?? false) || + (x.PrimaryEmail?.ToLowerInvariant().Contains(search) ?? false) || + (x.UserName?.ToLowerInvariant().Contains(search) ?? false) || + x.UserKey.Value.ToLowerInvariant().Contains(search)) + .ToList(); + } + + // SORT + summaries = query.SortBy switch + { + nameof(UserSummary.DisplayName) => + query.Descending + ? summaries.OrderByDescending(x => x.DisplayName).ToList() + : summaries.OrderBy(x => x.DisplayName).ToList(), + + nameof(UserSummary.CreatedAt) => + query.Descending + ? summaries.OrderByDescending(x => x.CreatedAt).ToList() + : summaries.OrderBy(x => x.CreatedAt).ToList(), + + _ => summaries.OrderBy(x => x.CreatedAt).ToList() + }; + + var total = summaries.Count; + + // PAGINATION + var items = summaries + .Skip((query.PageNumber - 1) * query.PageSize) + .Take(query.PageSize) + .ToList(); + + return new PagedResult( + items, + total, + query.PageNumber, + query.PageSize, + query.SortBy, + query.Descending); + }); + + return await _accessOrchestrator.ExecuteAsync(context, command, ct); + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs new file mode 100644 index 00000000..04e004ad --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStore.cs @@ -0,0 +1,20 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserIdentifierStore : IVersionedStore +{ + Task ExistsAsync(IdentifierExistenceQuery query, CancellationToken ct = default); + + Task> GetByUserAsync(UserKey userKey, CancellationToken ct = default); + Task GetByIdAsync(Guid id, CancellationToken ct = default); + Task GetAsync(UserIdentifierType type, string value, CancellationToken ct = default); + + Task> QueryAsync(UserIdentifierQuery query, CancellationToken ct = default); + + Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default); + Task DeleteByUserAsync(UserKey userKey, DeleteMode mode, DateTimeOffset deletedAt, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStoreFactory.cs new file mode 100644 index 00000000..e1c0ce04 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserIdentifierStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserIdentifierStoreFactory +{ + IUserIdentifierStore Create(TenantKey tenant); +} \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs new file mode 100644 index 00000000..c687f6da --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStore.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserLifecycleStore : IVersionedStore +{ + Task> QueryAsync(UserLifecycleQuery query, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStoreFactory.cs new file mode 100644 index 00000000..adb68dea --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserLifecycleStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserLifecycleStoreFactory +{ + IUserLifecycleStore Create(TenantKey tenant); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs new file mode 100644 index 00000000..5d63cb60 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStore.cs @@ -0,0 +1,11 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserProfileStore : IVersionedStore +{ + Task> QueryAsync(UserProfileQuery query, CancellationToken ct = default); + Task> GetByUsersAsync(IReadOnlyList userKeys, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStoreFactory.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStoreFactory.cs new file mode 100644 index 00000000..8ceeb0fe --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/IUserProfileStoreFactory.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +public interface IUserProfileStoreFactory +{ + IUserProfileStore Create(TenantKey tenant); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs new file mode 100644 index 00000000..f7b218e8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users.Reference/Stores/UserRuntimeStateProvider.cs @@ -0,0 +1,35 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Contracts; + +namespace CodeBeam.UltimateAuth.Users.Reference; + +internal sealed class UserRuntimeStateProvider : IUserRuntimeStateProvider +{ + private readonly IUserLifecycleStoreFactory _lifecycleStoreFactory; + + public UserRuntimeStateProvider(IUserLifecycleStoreFactory lifecycleStoreFactory) + { + _lifecycleStoreFactory = lifecycleStoreFactory; + } + + public async Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default) + { + var userLifecycleKey = new UserLifecycleKey(tenant, userKey); + var lifecycleStore = _lifecycleStoreFactory.Create(tenant); + var lifecycle = await lifecycleStore.GetAsync(userLifecycleKey, ct); + + if (lifecycle is null) + return null; + + return new UserRuntimeRecord + { + UserKey = lifecycle.UserKey, + IsActive = lifecycle.Status == UserStatus.Active, + CanAuthenticate = lifecycle.Status == UserStatus.Active || lifecycle.Status == UserStatus.SelfSuspended || lifecycle.Status == UserStatus.Suspended, + IsDeleted = lifecycle.IsDeleted, + Exists = true + }; + } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users.Reference/logo.png b/src/users/CodeBeam.UltimateAuth.Users.Reference/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/users/CodeBeam.UltimateAuth.Users.Reference/logo.png differ diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ICustomLoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ICustomLoginIdentifierResolver.cs new file mode 100644 index 00000000..8547287c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ICustomLoginIdentifierResolver.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +public interface ICustomLoginIdentifierResolver +{ + bool CanResolve(string identifier); + + Task ResolveAsync(TenantKey tenant, string identifier, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ILoginIdentifierResolver.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ILoginIdentifierResolver.cs new file mode 100644 index 00000000..0e06a7bb --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/ILoginIdentifierResolver.cs @@ -0,0 +1,8 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +public interface ILoginIdentifierResolver +{ + Task ResolveAsync(TenantKey tenant, string identifier, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IPrimaryUserIdentifierProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IPrimaryUserIdentifierProvider.cs new file mode 100644 index 00000000..1a4b9e7f --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IPrimaryUserIdentifierProvider.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IPrimaryUserIdentifierProvider +{ + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs new file mode 100644 index 00000000..b3cda978 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUser.cs @@ -0,0 +1,7 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Users; + +public interface IUser +{ + TUserId UserId { get; } + bool IsActive { get; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserCreateValidator.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserCreateValidator.cs new file mode 100644 index 00000000..598cfafc --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserCreateValidator.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserCreateValidator +{ + Task ValidateAsync(AccessContext context, CreateUserRequest request, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs new file mode 100644 index 00000000..c07c0ac8 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleIntegration.cs @@ -0,0 +1,16 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Users; + +/// +/// Optional integration point for reacting to user lifecycle events. +/// Implemented by plugin domains (Credentials, Authorization, Audit, etc). +/// +public interface IUserLifecycleIntegration +{ + Task OnUserCreatedAsync(TenantKey tenant, UserKey userKey, object request, CancellationToken ct = default); + + Task OnUserDeletedAsync(TenantKey tenant, UserKey userKey, DeleteMode mode, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs new file mode 100644 index 00000000..afaad513 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserLifecycleSnapshotProvider.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserLifecycleSnapshotProvider +{ + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs new file mode 100644 index 00000000..38b2f00c --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserProfileSnapshotProvider.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserProfileSnapshotProvider +{ + Task GetAsync(TenantKey tenant, UserKey userKey, CancellationToken ct = default); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs new file mode 100644 index 00000000..01dea3d1 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Abstractions/IUserSecurityEvents.cs @@ -0,0 +1,10 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Users; + +public interface IUserSecurityEvents +{ + Task OnUserActivatedAsync(UserKey userKey); + Task OnUserDeactivatedAsync(UserKey userKey); + Task OnSecurityInvalidatedAsync(UserKey userKey); +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs b/src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs new file mode 100644 index 00000000..7e3ab71a --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/AssemblyVisibility.cs @@ -0,0 +1,4 @@ +๏ปฟusing System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Tests.Unit")] +[assembly: InternalsVisibleTo("CodeBeam.UltimateAuth.Users.InMemory")] diff --git a/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj new file mode 100644 index 00000000..62f2b6a7 --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/CodeBeam.UltimateAuth.Users.csproj @@ -0,0 +1,31 @@ +๏ปฟ + + + net8.0;net9.0;net10.0 + $(NoWarn);1591 + + CodeBeam.UltimateAuth.Users + + + Users module for UltimateAuth. + Provides orchestration, abstractions and dependency injection wiring for user management functionality. + Use with a persistence provider such as EntityFrameworkCore or InMemory. + This package is included transitively by CodeBeam.UltimateAuth.Server and usually does not need to be installed directly. + + + authentication;identity;users;module;auth-framework + logo.png + README.md + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/LoginIdentifierResolution.cs b/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/LoginIdentifierResolution.cs new file mode 100644 index 00000000..0c6f228b --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/Infrastructure/LoginIdentifierResolution.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Users.Contracts; + +namespace CodeBeam.UltimateAuth.Users; +public sealed record LoginIdentifierResolution +{ + public required TenantKey Tenant { get; init; } + public UserKey? UserKey { get; init; } + public required string RawIdentifier { get; init; } + public required string NormalizedIdentifier { get; init; } + + public UserIdentifierType? BuiltInType { get; init; } + public string? CustomType { get; init; } + + public bool IsVerified { get; init; } +} diff --git a/src/users/CodeBeam.UltimateAuth.Users/README.md b/src/users/CodeBeam.UltimateAuth.Users/README.md new file mode 100644 index 00000000..8c0cf3db --- /dev/null +++ b/src/users/CodeBeam.UltimateAuth.Users/README.md @@ -0,0 +1,22 @@ +๏ปฟ# UltimateAuth Users + +User management module for UltimateAuth. + +## Purpose + +This package provides: + +- Dependency injection setup +- User module orchestration +- Integration points for persistence providers + +## Does NOT include + +- Persistence (use EntityFrameworkCore or InMemory packages) +- Domain implementation (use Reference package if needed) + +โš ๏ธ This package is typically installed transitively via: + +- CodeBeam.UltimateAuth.Server + +In most cases, you do not need to install it directly unless you are building custom integrations or extensions. \ No newline at end of file diff --git a/src/users/CodeBeam.UltimateAuth.Users/logo.png b/src/users/CodeBeam.UltimateAuth.Users/logo.png new file mode 100644 index 00000000..aa51a469 Binary files /dev/null and b/src/users/CodeBeam.UltimateAuth.Users/logo.png differ diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs new file mode 100644 index 00000000..c7fc3b1f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AssemblyBehavior.cs @@ -0,0 +1 @@ +๏ปฟ[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs new file mode 100644 index 00000000..ebc06239 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerFactory.cs @@ -0,0 +1,12 @@ +๏ปฟusing Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class AuthServerFactory : WebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs new file mode 100644 index 00000000..46245237 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/AuthServerTests.cs @@ -0,0 +1,14 @@ +๏ปฟusing Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.VisualStudio.TestPlatform.TestHost; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class AuthServerTests : IClassFixture> +{ + private readonly WebApplicationFactory _factory; + + public AuthServerTests(WebApplicationFactory factory) + { + _factory = factory; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj new file mode 100644 index 00000000..74729080 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/CodeBeam.UltimateAuth.Tests.Integration.csproj @@ -0,0 +1,27 @@ +๏ปฟ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs new file mode 100644 index 00000000..a06b2b12 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Integration/LoginTests.cs @@ -0,0 +1,96 @@ +๏ปฟusing FluentAssertions; +using Microsoft.AspNetCore.Mvc.Testing; +using System.Net; +using System.Net.Http.Json; + +namespace CodeBeam.UltimateAuth.Tests.Integration; + +public class LoginTests : IClassFixture +{ + private readonly HttpClient _client; + + public LoginTests(AuthServerFactory factory) + { + _client = factory.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + HandleCookies = false + }); + + _client.DefaultRequestHeaders.Add("Origin", "https://localhost:6130"); + _client.DefaultRequestHeaders.Add("X-UDID", "test-device-1234567890123456"); + } + + [Fact] + public async Task Login_Should_Return_Cookie() + { + var response = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + response.StatusCode.Should().Be(HttpStatusCode.Found); + response.Headers.Location.Should().NotBeNull(); + response.Headers.TryGetValues("Set-Cookie", out var cookies).Should().BeTrue(); + cookies.Should().NotBeNull(); + } + + [Fact] + public async Task Session_Lifecycle_Should_Work_Correctly() + { + var loginResponse1 = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + loginResponse1.StatusCode.Should().Be(HttpStatusCode.Found); + + var cookie1 = loginResponse1.Headers.GetValues("Set-Cookie").FirstOrDefault(); + cookie1.Should().NotBeNull(); + + _client.DefaultRequestHeaders.Add("Cookie", cookie1!); + + var logoutResponse = await _client.PostAsync("/auth/logout", null); + logoutResponse.StatusCode.Should().Be(HttpStatusCode.Found); + + var logoutAgain = await _client.PostAsync("/auth/logout", null); + logoutAgain.StatusCode.Should().BeOneOf(HttpStatusCode.Unauthorized, HttpStatusCode.Found); + + _client.DefaultRequestHeaders.Remove("Cookie"); + + var loginResponse2 = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + loginResponse2.StatusCode.Should().Be(HttpStatusCode.Found); + var cookie2 = loginResponse2.Headers.GetValues("Set-Cookie").FirstOrDefault(); + cookie2.Should().NotBeNull(); + cookie2.Should().NotBe(cookie1); + } + + [Fact] + public async Task Authenticated_User_Should_Access_Me_Endpoint() + { + var loginResponse = await _client.PostAsJsonAsync("/auth/login", new + { + identifier = "admin", + secret = "admin" + }); + + var cookie = loginResponse.Headers.GetValues("Set-Cookie").First(); + _client.DefaultRequestHeaders.Add("Cookie", cookie); + var response = await _client.PostAsync("/auth/me/get", null); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task Anonymous_Should_Not_Access_Me() + { + var response = await _client.PostAsync("/auth/me/get", null); + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs new file mode 100644 index 00000000..c7fc3b1f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/AssemblyBehavior.cs @@ -0,0 +1 @@ +๏ปฟ[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs new file mode 100644 index 00000000..74578556 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/CompiledPermissionSetTests.cs @@ -0,0 +1,113 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class CompiledPermissionSetTests +{ + [Fact] + public void ExactPermission_Allows_Action() + { + var permissions = new[] + { + Permission.From("users.delete.admin") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + } + + [Fact] + public void ExactPermission_DoesNotAllow_OtherAction() + { + var permissions = new[] + { + Permission.From("users.delete.admin") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("users.create.admin")); + } + + [Fact] + public void WildcardPermission_AllowsEverything() + { + var permissions = new[] + { + Permission.Wildcard + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + Assert.True(set.IsAllowed("anything.really.admin")); + } + + [Fact] + public void PrefixPermission_AllowsChildren() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.delete.admin")); + Assert.True(set.IsAllowed("users.profile.update.admin")); + } + + [Fact] + public void PrefixPermission_DoesNotAllowOtherResource() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("sessions.revoke.admin")); + } + + [Fact] + public void NestedPrefixPermission_Works() + { + var permissions = new[] + { + Permission.From("users.profile.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.True(set.IsAllowed("users.profile.update.admin")); + Assert.True(set.IsAllowed("users.profile.get.self")); + } + + [Fact] + public void NestedPrefixPermission_DoesNotMatchParent() + { + var permissions = new[] + { + Permission.From("users.profile.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("users.delete.admin")); + } + + [Fact] + public void PrefixPermission_DoesNotMatchSimilarPrefix() + { + var permissions = new[] + { + Permission.From("users.*") + }; + + var set = new CompiledPermissionSet(permissions); + + Assert.False(set.IsAllowed("usersettings.update.admin")); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs new file mode 100644 index 00000000..45df809b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Authorization/UAuthActionsTests.cs @@ -0,0 +1,47 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Actions; + +public class UAuthActionsTests +{ + [Fact] + public void Create_WithResourceOperationScope_Works() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.Equal("users.delete.admin", action); + } + + [Fact] + public void Create_WithSubResource_Works() + { + var action = UAuthActions.Create("users", "update", ActionScope.Self, "profile"); + Assert.Equal("users.profile.update.self", action); + } + + [Fact] + public void Create_ProducesLowercaseScope() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.Equal("users.delete.admin", action); + } + + [Fact] + public void Create_DifferentScopes_Work() + { + var self = UAuthActions.Create("users", "update", ActionScope.Self); + var admin = UAuthActions.Create("users", "delete", ActionScope.Admin); + var anon = UAuthActions.Create("users", "create", ActionScope.Anonymous); + + Assert.Equal("users.update.self", self); + Assert.Equal("users.delete.admin", admin); + Assert.Equal("users.create.anonymous", anon); + } + + [Fact] + public void Create_DoesNotCreateDoubleDots_WhenSubResourceNull() + { + var action = UAuthActions.Create("users", "delete", ActionScope.Admin); + Assert.DoesNotContain("..", action); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthAppTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthAppTests.cs new file mode 100644 index 00000000..fa05c157 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthAppTests.cs @@ -0,0 +1,121 @@ +๏ปฟusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthAppTests +{ + + private (BunitContext ctx, Mock stateManager, Mock bootstrapper, Mock coordinator) + + CreateUAuthAppTestContext(UAuthState state, bool authenticated = true) + { + var ctx = new BunitContext(); + + var stateManager = new Mock(); + stateManager.Setup(x => x.State).Returns(state); + stateManager.Setup(x => x.EnsureAsync(It.IsAny())) + .Returns(Task.CompletedTask); + + var bootstrapper = new Mock(); + bootstrapper.Setup(x => x.EnsureStartedAsync()) + .Returns(Task.CompletedTask); + + var coordinator = new Mock(); + coordinator.Setup(x => x.StartAsync()).Returns(Task.CompletedTask); + coordinator.Setup(x => x.StopAsync()).Returns(Task.CompletedTask); + + ctx.Services.AddSingleton(stateManager.Object); + ctx.Services.AddSingleton(bootstrapper.Object); + ctx.Services.AddSingleton(coordinator.Object); + + var auth = ctx.AddAuthorization(); + if (authenticated) + auth.SetAuthorized("test-user"); + else + auth.SetNotAuthorized(); + + return (ctx, stateManager, bootstrapper, coordinator); + } + + [Fact] + public async Task Should_Initialize_And_Bootstrap_On_First_Render() + { + var state = TestAuthState.Anonymous(); + var (ctx, stateManager, bootstrapper, _) = CreateUAuthAppTestContext(state); + var cut = ctx.Render(); + await cut.InvokeAsync(() => Task.CompletedTask); + + bootstrapper.Verify(x => x.EnsureStartedAsync(), Times.Once); + stateManager.Verify(x => x.EnsureAsync(It.IsAny()), Times.AtLeastOnce); + } + + [Fact] + public async Task Should_Start_Coordinator_When_Authenticated() + { + var state = TestAuthState.Authenticated(); + var (ctx, _, _, coordinator) = CreateUAuthAppTestContext(state); + var cut = ctx.Render(); + await cut.InvokeAsync(() => Task.CompletedTask); + + coordinator.Verify(x => x.StartAsync(), Times.Once); + } + + [Fact] + public async Task Should_Stop_Coordinator_When_State_Cleared() + { + var state = TestAuthState.Authenticated(); + var (ctx, _, _, coordinator) = CreateUAuthAppTestContext(state); + var cut = ctx.Render(); + state.Clear(); + await cut.InvokeAsync(() => Task.CompletedTask); + + coordinator.Verify(x => x.StopAsync(), Times.Once); + } + + [Fact] + public async Task Should_Stop_Coordinator_On_Dispose() + { + var state = TestAuthState.Authenticated(); + var (ctx, _, _, coordinator) = CreateUAuthAppTestContext(state); + var cut = ctx.Render(); + await cut.Instance.DisposeAsync(); + + coordinator.Verify(x => x.StopAsync(), Times.Once); + } + + [Fact] + public async Task Should_Call_Ensure_When_State_Is_Stale() + { + var state = TestAuthState.Authenticated(); + state.MarkStale(); + var (ctx, stateManager, _, _) = CreateUAuthAppTestContext(state); + var cut = ctx.Render(); + await cut.InvokeAsync(() => Task.CompletedTask); + + stateManager.Verify(x => x.EnsureAsync(true), Times.AtLeastOnce); + } + + [Fact] + public async Task Should_Invoke_Callback_On_Reauth() + { + var state = TestAuthState.Authenticated(); + var (ctx, _, _, coordinator) = CreateUAuthAppTestContext(state); + var called = false; + var cut = ctx.Render(p => p.Add(x => x.OnReauthRequired, EventCallback.Factory.Create(this, () => called = true))); + + await cut.InvokeAsync(() => Task.CompletedTask); + coordinator.Raise(x => x.ReauthRequired += null); + await cut.InvokeAsync(() => Task.CompletedTask); + + called.Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs new file mode 100644 index 00000000..fdb5e96d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthLoginFormTests.cs @@ -0,0 +1,214 @@ +๏ปฟusing Bunit; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthLoginFormTests +{ + [Fact] + public void Should_Render_Form() + { + using var ctx = new BunitContext(); + + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + var cut = ctx.Render(); + + cut.Find("form").Should().NotBeNull(); + } + + [Fact] + public void Should_Render_Identifier_And_Secret_Inputs() + { + using var ctx = new BunitContext(); + + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + var cut = ctx.Render(p => p + .Add(x => x.Identifier, "user") + .Add(x => x.Secret, "pass")); + + cut.Markup.Should().Contain("name=\"Identifier\""); + cut.Markup.Should().Contain("name=\"Secret\""); + } + + [Fact] + public async Task Submit_Should_Call_TryLogin() + { + using var ctx = new BunitContext(); + + var flowMock = new Mock(); + + flowMock.Setup(x => x.TryLoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TryLoginResult { Success = true }); + + var clientMock = new Mock(); + clientMock.Setup(x => x.Flows).Returns(flowMock.Object); + + ctx.Services.AddSingleton(clientMock.Object); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + var cut = ctx.Render(p => p + .Add(x => x.Identifier, "user") + .Add(x => x.Secret, "pass")); + + await cut.Instance.SubmitAsync(); + + flowMock.Verify(x => + x.TryLoginAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Submit_Should_Throw_When_Missing_Credentials() + { + using var ctx = new BunitContext(); + + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + var cut = ctx.Render(); + + Func act = async () => await cut.Instance.SubmitAsync(); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task SubmitPkce_Should_Call_TryCompletePkce() + { + using var ctx = new BunitContext(); + + var flowMock = new Mock(); + + flowMock.Setup(x => x.TryCompletePkceLoginAsync( + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TryPkceLoginResult { Success = true }); + + var clientMock = new Mock(); + clientMock.Setup(x => x.Flows).Returns(flowMock.Object); + + var credResolver = new Mock(); + credResolver.Setup(x => x.ResolveAsync(It.IsAny())) + .ReturnsAsync(new HubCredentials + { + AuthorizationCode = "code", + CodeVerifier = "verifier" + }); + + var capabilities = new Mock(); + capabilities.Setup(x => x.SupportsPkce).Returns(true); + + ctx.Services.AddSingleton(clientMock.Object); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(credResolver.Object); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(capabilities.Object); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + var hubSessionId = HubSessionId.New(); + + var cut = ctx.Render(p => p + .Add(x => x.Identifier, "user") + .Add(x => x.Secret, "pass") + .Add(x => x.LoginType, UAuthLoginType.Pkce) + .Add(x => x.HubSessionId, hubSessionId)); + + await cut.InvokeAsync(() => Task.CompletedTask); + await cut.Instance.SubmitAsync(); + + flowMock.Verify(x => + x.TryCompletePkceLoginAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task Should_Invoke_OnTryResult() + { + using var ctx = new BunitContext(); + + var flowMock = new Mock(); + + flowMock.Setup(x => x.TryLoginAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TryLoginResult { Success = true }); + + var clientMock = new Mock(); + clientMock.Setup(x => x.Flows).Returns(flowMock.Object); + + var invoked = false; + + ctx.Services.AddSingleton(clientMock.Object); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + var cut = ctx.Render(p => p + .Add(x => x.Identifier, "user") + .Add(x => x.Secret, "pass") + .Add(x => x.OnTryResult, EventCallback.Factory.Create(this, _ => invoked = true))); + + await cut.Instance.SubmitAsync(); + + invoked.Should().BeTrue(); + } + + [Fact] + public void Should_Throw_When_Pkce_Not_Supported() + { + using var ctx = new BunitContext(); + + var capabilities = new Mock(); + capabilities.Setup(x => x.SupportsPkce).Returns(false); + + ctx.Services.AddSingleton(capabilities.Object); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton(Mock.Of()); + ctx.Services.AddSingleton>(Options.Create(new UAuthClientOptions())); + + Action act = () => ctx.Render(p => p + .Add(x => x.LoginType, UAuthLoginType.Pkce)); + + act.Should().Throw(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs new file mode 100644 index 00000000..e53b5840 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Bunit/UAuthStateViewTests.cs @@ -0,0 +1,152 @@ +๏ปฟusing Bunit; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.Reference; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthStateViewTests +{ + private IRenderedComponent RenderWithAuth(BunitContext ctx, UAuthState state, Action> parameters) + { + var wrapper = ctx.Render>(p => p + .Add(x => x.Value, state) + .AddChildContent(parameters) + ); + + return wrapper.FindComponent(); + } + + private static RenderFragment Html(string html) + => b => b.AddMarkupContent(0, html); + + [Fact] + public void Should_Render_NotAuthorized_When_Not_Authenticated() + { + using var ctx = new BunitContext(); + var state = UAuthState.Anonymous(); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = ctx.Render>(parameters => parameters + .Add(p => p.Value, state) + .AddChildContent(child => child + .Add(p => p.NotAuthorized, (RenderFragment)(b => b.AddMarkupContent(0, "
nope
"))) + ) + ); + + cut.Markup.Should().Contain("nope"); + } + + [Fact] + public void Should_Render_ChildContent_When_Authorized_Without_Conditions() + { + using var ctx = new BunitContext(); + var state = TestAuthState.Authenticated(); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.ChildContent, s => b => b.AddContent(0, "authorized")) + ); + + cut.Markup.Should().Contain("authorized"); + } + + [Fact] + public void Should_Render_NotAuthorized_When_Role_Not_Match() + { + using var ctx = new BunitContext(); + var state = TestAuthState.WithRoles("user"); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin") + .Add(x => x.NotAuthorized, Html("
nope
")) + ); + + cut.Markup.Should().Contain("nope"); + } + + [Fact] + public void Should_Render_Authorized_When_Role_Matches() + { + using var ctx = new BunitContext(); + var state = TestAuthState.WithRoles("admin"); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin") + .Add(x => x.Authorized, s => b => b.AddContent(0, "ok")) + ); + + cut.Markup.Should().Contain("ok"); + } + + [Fact] + public void Should_Check_Permissions() + { + using var ctx = new BunitContext(); + var state = TestAuthState.WithPermissions("read"); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Permissions, "write") + .Add(x => x.NotAuthorized, Html("
no
")) + ); + + cut.Markup.Should().Contain("no"); + } + + [Fact] + public void Should_Require_All_When_MatchAll_True() + { + using var ctx = new BunitContext(); + var state = TestAuthState.WithRoles("admin"); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin,user") + .Add(x => x.MatchAll, true) + .Add(x => x.NotAuthorized, Html("
no
")) + ); + + cut.Markup.Should().Contain("no"); + } + + [Fact] + public void Should_Allow_Any_When_MatchAll_False() + { + using var ctx = new BunitContext(); + var state = TestAuthState.WithRoles("admin"); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Roles, "admin,user") + .Add(x => x.MatchAll, false) + .Add(x => x.Authorized, s => b => b.AddContent(0, "ok")) + ); + + cut.Markup.Should().Contain("ok"); + } + + [Fact] + public void Should_Render_Inactive_When_Session_Not_Active() + { + using var ctx = new BunitContext(); + var state = TestAuthState.WithSession(SessionState.Revoked); + ctx.Services.AddSingleton(Mock.Of()); + + var cut = RenderWithAuth(ctx, state, p => p + .Add(x => x.Inactive, s => b => b.AddContent(0, "inactive")) + ); + + cut.Markup.Should().Contain("inactive"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs new file mode 100644 index 00000000..e2acf3aa --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/AuthStateSnapshotFactoryTests.cs @@ -0,0 +1,61 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Users; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class AuthStateSnapshotFactoryTests +{ + [Fact] + public async Task CreateAsync_should_return_snapshot_when_valid() + { + var provider = new Mock(); + var pprovider = new Mock(); + var lprovider = new Mock(); + + provider.Setup(x => x.GetAsync(It.IsAny(), It.IsAny(), default)) + .ReturnsAsync(new PrimaryUserIdentifiers + { + UserName = "admin" + }); + + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); + + var validation = SessionValidationResult.Active( + TenantKey.FromInternal("__single__"), + UserKey.FromGuid(Guid.NewGuid()), + default, + default, + default, + ClaimsSnapshot.Empty, + DateTimeOffset.UtcNow + ); + + var snapshot = await factory.CreateAsync(validation); + + snapshot.Should().NotBeNull(); + snapshot!.Identity.PrimaryUserName.Should().Be("admin"); + } + + [Fact] + public async Task CreateAsync_should_return_null_when_invalid() + { + var provider = new Mock(); + var pprovider = new Mock(); + var lprovider = new Mock(); + + var factory = new AuthStateSnapshotFactory(provider.Object, pprovider.Object, lprovider.Object); + var validation = SessionValidationResult.Invalid(SessionState.NotFound); + + var snapshot = await factory.CreateAsync(validation); + snapshot.Should().BeNull(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs new file mode 100644 index 00000000..f3d7dd4c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientDiagnosticsTests.cs @@ -0,0 +1,105 @@ +๏ปฟusing System; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientDiagnosticsTests +{ + [Fact] + public void MarkStarted_ShouldSetStartedAt_AndIncrementCounter() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkStarted(); + + Assert.NotNull(diagnostics.StartedAt); + Assert.Equal(1, diagnostics.StartCount); + Assert.False(diagnostics.IsTerminated); + } + + [Fact] + public void MarkStopped_ShouldSetStoppedAt_AndIncrementCounter() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkStopped(); + + Assert.NotNull(diagnostics.StoppedAt); + Assert.Equal(1, diagnostics.StopCount); + } + + [Fact] + public void MarkManualRefresh_ShouldIncrementManualAndTotalCounters() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkManualRefresh(); + + Assert.Equal(1, diagnostics.RefreshAttemptCount); + Assert.Equal(1, diagnostics.ManualRefreshCount); + Assert.Equal(0, diagnostics.AutomaticRefreshCount); + } + + [Fact] + public void MarkAutomaticRefresh_ShouldIncrementAutomaticAndTotalCounters() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkAutomaticRefresh(); + + Assert.Equal(1, diagnostics.RefreshAttemptCount); + Assert.Equal(1, diagnostics.AutomaticRefreshCount); + Assert.Equal(0, diagnostics.ManualRefreshCount); + } + + [Fact] + public void MarkRefreshOutcomes_ShouldIncrementCorrectCounters() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkRefreshTouched(); + diagnostics.MarkRefreshNoOp(); + diagnostics.MarkRefreshReauthRequired(); + diagnostics.MarkRefreshSuccess(); + + Assert.Equal(1, diagnostics.RefreshTouchedCount); + Assert.Equal(1, diagnostics.RefreshNoOpCount); + Assert.Equal(1, diagnostics.RefreshReauthRequiredCount); + Assert.Equal(1, diagnostics.RefreshSuccessCount); + } + + [Fact] + public void MarkTerminated_ShouldSetTerminationState_AndIncrementCounter() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + + Assert.True(diagnostics.IsTerminated); + Assert.Equal(CoordinatorTerminationReason.ReauthRequired, diagnostics.TerminationReason); + Assert.Equal(1, diagnostics.TerminatedCount); + } + + [Fact] + public void ChangedEvent_ShouldFire_OnStateChanges() + { + var diagnostics = new UAuthClientDiagnostics(); + int changeCount = 0; + + diagnostics.Changed += () => changeCount++; + + diagnostics.MarkStarted(); + diagnostics.MarkManualRefresh(); + diagnostics.MarkRefreshTouched(); + diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + + Assert.Equal(4, changeCount); + } + + [Fact] + public void MarkTerminated_ShouldBeIdempotent_ForStateButCountShouldIncrease() + { + var diagnostics = new UAuthClientDiagnostics(); + diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + diagnostics.MarkTerminated(CoordinatorTerminationReason.ReauthRequired); + + Assert.True(diagnostics.IsTerminated); + Assert.Equal(2, diagnostics.TerminatedCount); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs new file mode 100644 index 00000000..c897c7ae --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientOptionsValidatorTests.cs @@ -0,0 +1,82 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientOptionsValidatorTests +{ + [Fact] + public void ClientProfile_not_specified_and_autodetect_disabled_should_fail() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.ClientProfile = UAuthClientProfile.NotSpecified; + o.AutoDetectClientProfile = false; + }); + + services.AddSingleton, UAuthClientOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*ClientProfile*AutoDetectClientProfile*"); + } + + [Fact] + public void ClientEndpoint_basepath_empty_should_fail() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.Endpoints.BasePath = ""; + }); + + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () =>_ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*BasePath*"); + } + + [Fact] + public void Valid_client_options_should_pass() + { + var services = new ServiceCollection(); + + services.AddOptions() + .Configure(o => + { + o.ClientProfile = UAuthClientProfile.BlazorWasm; + o.AutoDetectClientProfile = false; + o.Endpoints.BasePath = "/auth"; + }); + + services.AddSingleton, UAuthClientOptionsValidator>(); + services.AddSingleton, UAuthClientEndpointOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.ClientProfile.Should().Be(UAuthClientProfile.BlazorWasm); + } + + [Fact] + public void Marker_Should_Not_Throw_On_First_Call() + { + var marker = new ClientConfigurationMarker(); + var act = () => marker.MarkConfigured(); + act.Should().NotThrow(); + } + + [Fact] + public void Marker_Should_Throw_When_Configured_Twice() + { + var marker = new ClientConfigurationMarker(); + marker.MarkConfigured(); + Action act = () => marker.MarkConfigured(); + act.Should().Throw(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProductInfoTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProductInfoTests.cs new file mode 100644 index 00000000..3f717360 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProductInfoTests.cs @@ -0,0 +1,82 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientProductInfoTests +{ + [Fact] + public void ProductInfo_Should_Be_Created_With_Valid_Data() + { + var options = Options.Create(new UAuthClientOptions + { + ClientProfile = UAuthClientProfile.BlazorServer, + AutoRefresh = new UAuthClientAutoRefreshOptions + { + Enabled = true, + Interval = TimeSpan.FromMinutes(5) + }, + Reauth = new UAuthClientReauthOptions + { + Behavior = ReauthBehavior.RaiseEvent + } + }); + + var provider = new UAuthClientProductInfoProvider(options); + + var info = provider.Get(); + + info.Should().NotBeNull(); + info.ProductName.Should().Be("UltimateAuth Client"); + info.ClientProfile.Should().Be(UAuthClientProfile.BlazorServer); + info.AutoRefreshEnabled.Should().BeTrue(); + info.RefreshInterval.Should().Be(TimeSpan.FromMinutes(5)); + info.ReauthBehavior.Should().Be(ReauthBehavior.RaiseEvent); + } + + [Fact] + public void ProductInfo_Should_Set_StartedAt() + { + var options = Options.Create(new UAuthClientOptions()); + var before = DateTimeOffset.UtcNow; + var provider = new UAuthClientProductInfoProvider(options); + var info = provider.Get(); + var after = DateTimeOffset.UtcNow; + + info.StartedAt.Should().BeAfter(before.AddSeconds(-1)); + info.StartedAt.Should().BeBefore(after.AddSeconds(1)); + } + + [Fact] + public void ProductInfo_Should_Return_Same_Instance() + { + var options = Options.Create(new UAuthClientOptions()); + var provider = new UAuthClientProductInfoProvider(options); + var info1 = provider.Get(); + var info2 = provider.Get(); + + info1.Should().BeSameAs(info2); + } + + [Fact] + public void ProductInfo_Should_Have_Version() + { + var options = Options.Create(new UAuthClientOptions()); + var provider = new UAuthClientProductInfoProvider(options); + var info = provider.Get(); + info.Version.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void ProductInfo_Should_Have_FrameworkDescription() + { + var options = Options.Create(new UAuthClientOptions()); + var provider = new UAuthClientProductInfoProvider(options); + var info = provider.Get(); + info.FrameworkDescription.Should().NotBeNullOrWhiteSpace(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProfileTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProfileTests.cs new file mode 100644 index 00000000..7dbeb1df --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/ClientProfileTests.cs @@ -0,0 +1,86 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientProfileTests +{ + [Fact] + public void PostConfigure_Should_Not_Change_When_AutoDetect_Disabled() + { + var detector = new Mock(); + var sut = new UAuthClientOptionsPostConfigure(detector.Object, new ServiceCollection().BuildServiceProvider()); + + var options = new UAuthClientOptions + { + AutoDetectClientProfile = false, + ClientProfile = UAuthClientProfile.NotSpecified + }; + + sut.PostConfigure(null, options); + options.ClientProfile.Should().Be(UAuthClientProfile.NotSpecified); + } + + [Fact] + public void PostConfigure_Should_Not_Override_Explicit_Profile() + { + var detector = new Mock(); + var sut = new UAuthClientOptionsPostConfigure(detector.Object, new ServiceCollection().BuildServiceProvider()); + + var options = new UAuthClientOptions + { + AutoDetectClientProfile = true, + ClientProfile = UAuthClientProfile.BlazorServer + }; + + sut.PostConfigure(null, options); + options.ClientProfile.Should().Be(UAuthClientProfile.BlazorServer); + } + + [Fact] + public void PostConfigure_Should_Set_Profile_From_Detector() + { + var detector = new Mock(); + + detector.Setup(x => x.Detect(It.IsAny())) + .Returns(UAuthClientProfile.Maui); + + var sut = new UAuthClientOptionsPostConfigure(detector.Object, new ServiceCollection().BuildServiceProvider()); + + var options = new UAuthClientOptions + { + AutoDetectClientProfile = true, + ClientProfile = UAuthClientProfile.NotSpecified + }; + + sut.PostConfigure(null, options); + options.ClientProfile.Should().Be(UAuthClientProfile.Maui); + } + + [Fact] + public void Detect_Should_Return_Hub_When_Marker_Exists() + { + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + + var sp = services.BuildServiceProvider(); + var detector = new UAuthClientProfileDetector(); + var result = detector.Detect(sp); + result.Should().Be(UAuthClientProfile.UAuthHub); + } + + // TODO: This test fails on CI, find a betterway to test or implementation logic + //[Fact] + //public void Detect_Should_Default_To_WebServer() + //{ + // var sp = new ServiceCollection().BuildServiceProvider(); + // var detector = new UAuthClientProfileDetector(); + // var result = detector.Detect(sp); + // result.Should().Be(UAuthClientProfile.WebServer); + //} +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs new file mode 100644 index 00000000..3a0cfd00 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/RefreshOutcomeParserTests.cs @@ -0,0 +1,32 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class RefreshOutcomeParserTests +{ + [Theory] + [InlineData("no-op", RefreshOutcome.NoOp)] + [InlineData("touched", RefreshOutcome.Touched)] + [InlineData("reauth-required", RefreshOutcome.ReauthRequired)] + public void Parse_KnownValues_ReturnsExpectedOutcome(string input, RefreshOutcome expected) + { + var result = RefreshOutcomeParser.Parse(input); + + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("unknown")] + [InlineData("NO-OP")] + [InlineData("Touched")] + public void Parse_UnknownOrInvalidValues_ReturnsNone(string? input) + { + var result = RefreshOutcomeParser.Parse(input); + + Assert.Equal(RefreshOutcome.Success, result); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs new file mode 100644 index 00000000..c3338855 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/SessionCoordinatorTests.cs @@ -0,0 +1,75 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Blazor.Infrastructure; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Options; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class SessionCoordinatorTests +{ + [Fact] + public async Task StartAsync_should_not_start_when_auto_refresh_disabled() + { + var clock = new TestClock(); + var client = new Mock(); + var nav = new Mock(); + var diagnostics = new UAuthClientDiagnostics(); + + var options = Options.Create(new UAuthClientOptions + { + AutoRefresh = new UAuthClientAutoRefreshOptions + { + Enabled = false + } + }); + + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); + await coordinator.StartAsync(); + + Assert.False(diagnostics.IsRunning); + } + + [Fact] + public async Task ReauthRequired_should_raise_event() + { + var clock = new TestClock(); + + var client = new Mock(); + var nav = new Mock(); + var diagnostics = new UAuthClientDiagnostics(); + + client.Setup(x => x.Flows.RefreshAsync(true)) + .ReturnsAsync(new RefreshResult + { + Outcome = RefreshOutcome.ReauthRequired + }); + + var options = Options.Create(new UAuthClientOptions + { + AutoRefresh = new UAuthClientAutoRefreshOptions + { + Enabled = true, + Interval = TimeSpan.FromSeconds(5) + }, + Reauth = new UAuthClientReauthOptions + { + Behavior = ReauthBehavior.RaiseEvent + } + }); + + var coordinator = new SessionCoordinator(client.Object, nav.Object, options, diagnostics, clock); + var triggered = false; + coordinator.ReauthRequired += () => triggered = true; + + await coordinator.TickAsync(); + + Assert.True(triggered); + Assert.True(diagnostics.IsTerminated); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs new file mode 100644 index 00000000..664248a7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientAuthorizationTests.cs @@ -0,0 +1,264 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientAuthorizationTests : UAuthClientTestBase +{ + [Fact] + public async Task AssignRole_Should_Call_Correct_Endpoint_And_Publish_Event() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateClient(); + + var request = new AssignRoleRequest + { + UserKey = UserKey.FromString("user-1"), + RoleName = "admin" + }; + + await client.Authorization.AssignRoleToUserAsync(request); + + Request.Verify(x => x.SendJsonAsync( $"/auth/admin/authorization/users/{request.UserKey.Value}/roles/assign", request.RoleName), Times.Once); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once); + } + + [Fact] + public async Task RemoveRole_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateClient(); + + var request = new RemoveRoleRequest + { + UserKey = UserKey.FromString("user-1"), + RoleName = "admin" + }; + + await client.Authorization.RemoveRoleFromUserAsync(request); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once); + } + + [Fact] + public async Task AssignRole_Should_NOT_Publish_Event_On_Failure() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); + + var client = CreateClient(); + + var request = new AssignRoleRequest + { + UserKey = UserKey.FromString("user-1"), + RoleName = "admin" + }; + + await client.Authorization.AssignRoleToUserAsync(request); + + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Check_Should_Return_Result() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new AuthorizationResult + { + IsAllowed = true + })); + + var client = CreateClient(); + var result = await client.Authorization.CheckAsync(new AuthorizationCheckRequest() { Action = UAuthActions.Authorization.Roles.CreateAdmin }); + result.IsSuccess.Should().BeTrue(); + result.Value!.IsAllowed.Should().BeTrue(); + } + + [Fact] + public async Task QueryRoles_Should_Return_Data() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new PagedResult( + new List(), + 0, 1, 10, null, false))); + + var client = CreateClient(); + var result = await client.Authorization.QueryRolesAsync(new RoleQuery()); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task GetMyRoles_Should_Call_Correct_Endpoint_And_Return_Data() + { + var userKey = UserKey.FromString("user-1"); + + var response = new UserRolesResponse + { + UserKey = userKey, + Roles = new PagedResult( + new List + { + new UserRoleInfo + { + Tenant = TenantKeys.Single, + UserKey = userKey, + RoleId = RoleId.From(Guid.NewGuid()), + Name = "admin", + AssignedAt = DateTimeOffset.UtcNow + } + }, + totalCount: 1, + pageNumber: 1, + pageSize: 10, + sortBy: null, + descending: false) + }; + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + var result = await client.Authorization.GetMyRolesAsync(); + Request.Verify(x => x.SendJsonAsync("/auth/me/authorization/roles/get", It.IsAny()), Times.Once); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.UserKey.Should().Be(userKey); + result.Value.Roles.Items.Should().HaveCount(1); + result.Value.Roles.Items[0].Name.Should().Be("admin"); + } + + [Fact] + public async Task GetUserRoles_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + var response = new UserRolesResponse + { + UserKey = userKey, + Roles = new PagedResult( + new List + { + new UserRoleInfo + { + Tenant = TenantKeys.Single, + UserKey = userKey, + RoleId = RoleId.From(Guid.NewGuid()), + Name = "admin", + AssignedAt = DateTimeOffset.UtcNow + } + }, + totalCount: 1, + pageNumber: 1, + pageSize: 10, + sortBy: null, + descending: false) + }; + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + var result = await client.Authorization.GetUserRolesAsync(userKey); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/authorization/users/{userKey.Value}/roles/get", It.IsAny()), Times.Once); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + result.Value!.UserKey.Should().Be(userKey); + result.Value.Roles.Items.Should().HaveCount(1); + result.Value.Roles.Items[0].Name.Should().Be("admin"); + } + + [Fact] + public async Task CreateRole_Should_Return_Result() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new RoleInfo() { Name = "admin" })); + + var client = CreateClient(); + var result = await client.Authorization.CreateRoleAsync(new CreateRoleRequest() { Name = "admin" }); + result.IsSuccess.Should().BeTrue(); + result.Value.Should().NotBeNull(); + } + + [Fact] + public async Task RenameRole_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + + var client = CreateClient(); + var request = new RenameRoleRequest + { + Id = RoleId.From(Guid.NewGuid()), + Name = "new-role" + }; + + await client.Authorization.RenameRoleAsync(request); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once); + } + + [Fact] + public async Task RenameRole_Should_NOT_Publish_Event_On_Failure() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); + + var client = CreateClient(); + + await client.Authorization.RenameRoleAsync(new RenameRoleRequest + { + Id = RoleId.From(Guid.NewGuid()), + Name = "fail" + }); + + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SetRolePermissions_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + + var client = CreateClient(); + + var request = new SetRolePermissionsRequest + { + RoleId = RoleId.From(Guid.NewGuid()), + Permissions = new List { Permission.From("read"), Permission.From("write") } + }; + + await client.Authorization.SetRolePermissionsAsync(request); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once); + } + + [Fact] + public async Task DeleteRole_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new DeleteRoleResult())); + + var client = CreateClient(); + + var request = new DeleteRoleRequest + { + Id = RoleId.From(Guid.NewGuid()) + }; + + await client.Authorization.DeleteRoleAsync(request); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.AuthorizationChanged)), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs new file mode 100644 index 00000000..c16255aa --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientBootstrapperTests.cs @@ -0,0 +1,48 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Runtime; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientBootstrapperTests +{ + [Fact] + public async Task EnsureStartedAsync_should_initialize_only_once() + { + var deviceId = TestDevice.Default().DeviceId!.Value; + + var provider = new Mock(); + provider.Setup(x => x.GetOrCreateAsync(default)).ReturnsAsync(deviceId); + + var browser = new Mock(); + + var bootstrapper = new UAuthClientBootstrapper(provider.Object, browser.Object); + + await bootstrapper.EnsureStartedAsync(); + await bootstrapper.EnsureStartedAsync(); + + provider.Verify(x => x.GetOrCreateAsync(default), Times.Once); + browser.Verify(x => x.SetDeviceIdAsync(deviceId.Value), Times.Once); + } + + [Fact] + public async Task EnsureStartedAsync_should_be_thread_safe() + { + var deviceId = TestDevice.Default().DeviceId!.Value; + + var provider = new Mock(); + provider.Setup(x => x.GetOrCreateAsync(default)).ReturnsAsync(deviceId); + + var browser = new Mock(); + var bootstrapper = new UAuthClientBootstrapper(provider.Object, browser.Object); + + var tasks = Enumerable.Range(0, 10).Select(_ => bootstrapper.EnsureStartedAsync()); + + await Task.WhenAll(tasks); + + provider.Verify(x => x.GetOrCreateAsync(default), Times.Once); + browser.Verify(x => x.SetDeviceIdAsync(deviceId.Value), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs new file mode 100644 index 00000000..758109cb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientCredentialsTests.cs @@ -0,0 +1,168 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientCredentialTests : UAuthClientTestBase +{ + [Fact] + public async Task AddMy_Should_Call_Correct_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new AddCredentialResult())); + + var client = CreateCredentialClient(); + await client.Credentials.AddMyAsync(new AddCredentialRequest() { Secret = "uauth" }); + Request.Verify(x => x.SendJsonAsync("/auth/me/credentials/add", It.IsAny()), Times.Once); + } + + [Fact] + public async Task ChangeMy_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new ChangeCredentialResult())); + + var client = CreateCredentialClient(); + await client.Credentials.ChangeMyAsync(new ChangeCredentialRequest() { NewSecret = "uauth" }); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.CredentialsChangedSelf)), Times.Once); + } + + [Fact] + public async Task ChangeMy_Should_NOT_Publish_Event_On_Failure() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); + + var client = CreateCredentialClient(); + await client.Credentials.ChangeMyAsync(new ChangeCredentialRequest() { NewSecret = "uauth" }); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RevokeMy_Should_Publish_Event() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + + var client = CreateCredentialClient(); + await client.Credentials.RevokeMyAsync(new RevokeCredentialRequest()); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddUser_Should_Call_Admin_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new AddCredentialResult())); + + var client = CreateCredentialClient(); + var userKey = UserKey.FromString("user-1"); + await client.Credentials.AddUserAsync(userKey, new AddCredentialRequest() { Secret = "uauth" }); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/credentials/add", It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteUser_Should_Call_Delete_Endpoint() + { + Request.Setup(x => x.SendFormAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + + var client = CreateCredentialClient(); + var userKey = UserKey.FromString("user-1"); + await client.Credentials.DeleteUserAsync(userKey, new DeleteCredentialRequest()); + Request.Verify(x => + x.SendFormAsync($"/auth/admin/users/{userKey.Value}/credentials/delete", + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task BeginResetMy_Should_Call_Correct_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new BeginCredentialResetResult())); + + var client = CreateCredentialClient(); + await client.Credentials.BeginResetMyAsync(new BeginResetCredentialRequest() { Identifier = "user1" }); + Request.Verify(x => x.SendJsonAsync("/auth/me/credentials/reset/begin", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompleteResetMy_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new CredentialActionResult())); + + var client = CreateCredentialClient(); + await client.Credentials.CompleteResetMyAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth" }); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.CredentialsChanged)), Times.Once); + } + + [Fact] + public async Task CompleteResetMy_Should_NOT_Publish_Event_On_Failure() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); + + var client = CreateCredentialClient(); + await client.Credentials.CompleteResetMyAsync(new CompleteResetCredentialRequest() { NewSecret = "uauth" }); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task BeginResetUser_Should_Call_Admin_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new BeginCredentialResetResult())); + + var client = CreateCredentialClient(); + var userKey = UserKey.FromString("user-1"); + + await client.Credentials.BeginResetUserAsync(userKey, new BeginResetCredentialRequest() { Identifier = "user1" }); + + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/credentials/reset/begin", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CompleteResetUser_Should_NOT_Publish_Event() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new CredentialActionResult())); + + var client = CreateCredentialClient(); + var userKey = UserKey.FromString("user-1"); + await client.Credentials.CompleteResetUserAsync(userKey, new CompleteResetCredentialRequest() { NewSecret = "uauth" }); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ChangeUser_Should_Call_Admin_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new ChangeCredentialResult())); + + var client = CreateCredentialClient(); + var userKey = UserKey.FromString("user-1"); + + await client.Credentials.ChangeUserAsync(userKey, new ChangeCredentialRequest() { NewSecret = "uauth" }); + + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/credentials/change", It.IsAny()), Times.Once); + } + + [Fact] + public async Task RevokeUser_Should_Call_Admin_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + + var client = CreateCredentialClient(); + var userKey = UserKey.FromString("user-1"); + await client.Credentials.RevokeUserAsync(userKey, new RevokeCredentialRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/credentials/revoke", It.IsAny()), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs new file mode 100644 index 00000000..a6f929b2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientSessionTests.cs @@ -0,0 +1,264 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientSessionTests +{ + private readonly Mock _request = new(); + private readonly Mock _events = new(); + + private IUAuthClient CreateClient() + { + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth" + } + }); + + var sessionClient = new UAuthSessionClient(_request.Object, options, _events.Object); + + return new UAuthClient( + flows: Mock.Of(), + session: sessionClient, + users: Mock.Of(), + identifiers: Mock.Of(), + credentials: Mock.Of(), + authorization: Mock.Of()); + } + + private static UAuthTransportResult Success() + => new() { Ok = true, Status = 200 }; + + private static UAuthTransportResult SuccessJson(T body) + => new() + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(body) + }; + + [Fact] + public async Task GetMyChains_Should_Call_Correct_Endpoint() + { + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + _request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + await client.Sessions.GetMyChainsAsync(); + _request.Verify(x => x.SendJsonAsync("/auth/me/sessions/chains", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetMyChainDetail_Should_Call_Correct_Endpoint() + { + var chainId = SessionChainId.New(); + + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(new SessionChainDetail + { + ChainId = chainId + })); + + var client = CreateClient(); + await client.Sessions.GetMyChainDetailAsync(chainId); + _request.Verify(x => x.SendFormAsync($"/auth/me/sessions/chains/{chainId.Value}"), Times.Once); + } + + [Fact] + public async Task GetUserChainDetail_Should_Call_Correct_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + var chainId = SessionChainId.New(); + + var response = new SessionChainDetail + { + ChainId = chainId + }; + + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + await client.Sessions.GetUserChainDetailAsync(userKey, chainId); + _request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/sessions/chains/{chainId.Value}"), Times.Once); + } + + [Fact] + public async Task RevokeMyChain_Should_Publish_Event_When_CurrentChain() + { + var response = new RevokeResult + { + CurrentChain = true + }; + + _request.Setup(x => x.SendJsonAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + var chainId = SessionChainId.From(Guid.NewGuid()); + + await client.Sessions.RevokeMyChainAsync(chainId); + + _events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.SessionRevoked)), Times.Once); + } + + [Fact] + public async Task RevokeMyChain_Should_NOT_Publish_Event_When_Not_CurrentChain() + { + var response = new RevokeResult + { + CurrentChain = false + }; + + _request.Setup(x => x.SendJsonAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + var chainId = SessionChainId.From(Guid.NewGuid()); + await client.Sessions.RevokeMyChainAsync(chainId); + + _events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RevokeUserChain_Should_Call_Correct_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + var chainId = SessionChainId.New(); + + var response = new RevokeResult + { + CurrentChain = false + }; + + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + await client.Sessions.RevokeUserChainAsync(userKey, chainId); + _request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/sessions/chains/{chainId.Value}/revoke"), Times.Once); + } + + [Fact] + public async Task RevokeAllMyChains_Should_Publish_Event_On_Success() + { + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = true, Status = 200 }); + + var client = CreateClient(); + await client.Sessions.RevokeAllMyChainsAsync(); + _events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.SessionRevoked)), Times.Once); + } + + [Fact] + public async Task RevokeAllMyChains_Should_NOT_Publish_Event_On_Failure() + { + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); + + var client = CreateClient(); + await client.Sessions.RevokeAllMyChainsAsync(); + _events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task RevokeAllUserChains_Should_Call_Correct_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateClient(); + await client.Sessions.RevokeAllUserChainsAsync(userKey); + _request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/sessions/revoke-all"), Times.Once); + } + + [Fact] + public async Task RevokeMyOtherChains_Should_Call_Correct_Endpoint() + { + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(Success); + + var client = CreateClient(); + await client.Sessions.RevokeMyOtherChainsAsync(); + _request.Verify(x => x.SendFormAsync("/auth/me/sessions/revoke-others"), Times.Once); + } + + [Fact] + public async Task GetUserChains_Should_Call_Admin_Endpoint() + { + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + _request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateClient(); + var userKey = UserKey.FromString("user-1"); + await client.Sessions.GetUserChainsAsync(userKey); + + _request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/sessions/chains", It.IsAny()), Times.Once); + } + + [Fact] + public async Task RevokeUserSession_Should_Call_Correct_Endpoint() + { + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(Success); + + var client = CreateClient(); + var userKey = UserKey.FromString("user-1"); + var sessionId = AuthSessionId.Parse("session-123456789123456789123456789", null); + + await client.Sessions.RevokeUserSessionAsync(userKey, sessionId); + + _request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/sessions/{sessionId.Value}/revoke"), Times.Once); + } + + [Fact] + public async Task RevokeUserRoot_Should_Call_Correct_Endpoint() + { + _request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(Success); + + var client = CreateClient(); + var userKey = UserKey.FromString("user-1"); + await client.Sessions.RevokeUserRootAsync(userKey); + _request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/sessions/revoke-root"), Times.Once); + } + + [Fact] + public async Task RevokeMyChain_Should_NOT_Publish_Event_When_Value_Null() + { + _request.Setup(x => x.SendJsonAsync(It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = null + }); + + var client = CreateClient(); + var chainId = SessionChainId.New(); + await client.Sessions.RevokeMyChainAsync(chainId); + _events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserIdentifiersTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserIdentifiersTests.cs new file mode 100644 index 00000000..def61741 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserIdentifiersTests.cs @@ -0,0 +1,210 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientUserIdentifiersTests : UAuthClientTestBase +{ + [Fact] + public async Task GetMy_Should_Call_Correct_Endpoint() + { + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateIdentifierClient(); + await client.Identifiers.GetMyAsync(); + Request.Verify(x => x.SendJsonAsync("/auth/me/identifiers/get", It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddMy_Should_Publish_Event_On_Success() + { + var request = new AddUserIdentifierRequest() { Value = "uauth" }; + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.AddMyAsync(request); + + Events.Verify(x => + x.PublishAsync(It.Is>(e => + e.Type == UAuthStateEvent.IdentifiersChanged && + e.Payload == request)), + Times.Once); + } + + [Fact] + public async Task AddMy_Should_NOT_Publish_Event_On_Failure() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Failure()); + + var client = CreateIdentifierClient(); + await client.Identifiers.AddMyAsync(new AddUserIdentifierRequest() { Value = "uauth" }); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task UpdateMy_Should_Publish_Event_On_Success() + { + var request = new UpdateUserIdentifierRequest() { NewValue = "uauth" }; + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.UpdateMyAsync(request); + Events.Verify(x => x.PublishAsync(It.Is>(e => e.Payload == request)), Times.Once); + } + + [Fact] + public async Task SetMyPrimary_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.SetMyPrimaryAsync(new SetPrimaryUserIdentifierRequest()); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.IdentifiersChanged)), Times.Once); + } + + [Fact] + public async Task UnsetMyPrimary_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.UnsetMyPrimaryAsync(new UnsetPrimaryUserIdentifierRequest()); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.IdentifiersChanged)), Times.Once); + } + + [Fact] + public async Task VerifyMy_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.VerifyMyAsync(new VerifyUserIdentifierRequest()); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.IdentifiersChanged)), Times.Once); + } + + [Fact] + public async Task DeleteMy_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.DeleteMyAsync(new DeleteUserIdentifierRequest()); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.IdentifiersChanged)), Times.Once); + } + + [Fact] + public async Task GetUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateIdentifierClient(); + await client.Identifiers.GetUserAsync(userKey); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/identifiers/get", It.IsAny()), Times.Once); + } + + [Fact] + public async Task AddUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.AddUserAsync(userKey, new AddUserIdentifierRequest() { Value = "uauth"}); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey}/identifiers/add", It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.DeleteUserAsync(userKey, new DeleteUserIdentifierRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey}/identifiers/delete", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.UpdateUserAsync(userKey, new UpdateUserIdentifierRequest() { NewValue = "uauth" }); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/identifiers/update", It.IsAny()), Times.Once); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task SetUserPrimary_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.SetUserPrimaryAsync(userKey, new SetPrimaryUserIdentifierRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/identifiers/set-primary", It.IsAny()), Times.Once); + } + + [Fact] + public async Task UnsetUserPrimary_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.UnsetUserPrimaryAsync(userKey, new UnsetPrimaryUserIdentifierRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/identifiers/unset-primary", It.IsAny()), Times.Once); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task VerifyUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateIdentifierClient(); + await client.Identifiers.VerifyUserAsync(userKey, new VerifyUserIdentifierRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/identifiers/verify", It.IsAny()), Times.Once); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs new file mode 100644 index 00000000..eb2cbf2c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthClientUserTests.cs @@ -0,0 +1,208 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthClientUserTests : UAuthClientTestBase +{ + [Fact] + public async Task GetMe_Should_Call_Correct_Endpoint() + { + var response = new UserView + { + UserKey = UserKey.FromString("user-1") + }; + + Request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateUserClient(); + await client.Users.GetMeAsync(); + Request.Verify(x => x.SendFormAsync("/auth/me/get"), Times.Once); + } + + [Fact] + public async Task UpdateMe_Should_Publish_Event_On_Success() + { + var request = new UpdateProfileRequest(); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateUserClient(); + await client.Users.UpdateMeAsync(request); + Events.Verify(x => + x.PublishAsync(It.Is>(e => + e.Type == UAuthStateEvent.ProfileChanged && + e.Payload == request)), + Times.Once); + } + + [Fact] + public async Task UpdateMe_Should_NOT_Publish_Event_On_Failure() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult { Ok = false, Status = 400 }); + + var client = CreateUserClient(); + await client.Users.UpdateMeAsync(new UpdateProfileRequest()); + Events.Verify(x => x.PublishAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task DeleteMe_Should_Publish_Event_On_Success() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateUserClient(); + await client.Users.DeleteMeAsync(); + Events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.UserDeleted)), Times.Once); + } + + [Fact] + public async Task Query_Should_Call_Admin_Endpoint() + { + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateUserClient(); + await client.Users.QueryAsync(new UserQuery()); + Request.Verify(x => x.SendJsonAsync("/auth/admin/users/query", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Create_Should_Call_Public_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new UserCreateResult() { Succeeded = true })); + + var client = CreateUserClient(); + await client.Users.CreateAsync(new CreateUserRequest()); + Request.Verify(x => x.SendJsonAsync("/auth/users/create", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CreateAsAdmin_Should_Call_Admin_Endpoint() + { + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new UserCreateResult() { Succeeded = true})); + + var client = CreateUserClient(); + await client.Users.CreateAsAdminAsync(new CreateUserRequest()); + Request.Verify(x => x.SendJsonAsync("/auth/admin/users/create", It.IsAny()), Times.Once); + } + + [Fact] + public async Task ChangeMyStatus_Should_Publish_Event_On_Success() + { + var request = new ChangeUserStatusSelfRequest() { NewStatus = SelfAssignableUserStatus.Active }; + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new UserStatusChangeResult() { Succeeded = true })); + + var client = CreateUserClient(); + await client.Users.ChangeMyStatusAsync(request); + Events.Verify(x => + x.PublishAsync(It.Is>(e => + e.Type == UAuthStateEvent.ProfileChanged && + e.Payload == request)), + Times.Once); + } + + [Fact] + public async Task ChangeUserStatus_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new UserStatusChangeResult() { Succeeded = true })); + + var client = CreateUserClient(); + await client.Users.ChangeUserStatusAsync(userKey, new ChangeUserStatusAdminRequest() { NewStatus = AdminAssignableUserStatus.Active }); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/status", It.IsAny()), Times.Once); + } + + [Fact] + public async Task DeleteUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(new UserDeleteResult() { Succeeded = true, Mode = DeleteMode.Soft })); + + var client = CreateUserClient(); + await client.Users.DeleteUserAsync(userKey, new DeleteUserRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/delete", It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + var response = new UserView + { + UserKey = userKey + }; + + Request.Setup(x => x.SendFormAsync(It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateUserClient(); + await client.Users.GetUserAsync(userKey); + Request.Verify(x => x.SendFormAsync($"/auth/admin/users/{userKey.Value}/profile/get"), Times.Once); + } + + [Fact] + public async Task UpdateUser_Should_Call_Admin_Endpoint() + { + var userKey = UserKey.FromString("user-1"); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateUserClient(); + await client.Users.UpdateUserAsync(userKey, new UpdateProfileRequest()); + Request.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/profile/update", It.IsAny()), Times.Once); + } + + [Fact] + public async Task Query_Should_Create_Default_Query_When_Null() + { + var response = new PagedResult( + new List(), + 0, 1, 10, null, false); + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(SuccessJson(response)); + + var client = CreateUserClient(); + await client.Users.QueryAsync(null!); + Request.Verify(x => x.SendJsonAsync("/auth/admin/users/query", It.Is(o => o is UserQuery)), Times.Once); + } + + [Fact] + public async Task Query_Should_Use_Given_Query() + { + var query = new UserQuery + { + }; + + Request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Success()); + + var client = CreateUserClient(); + await client.Users.QueryAsync(query); + Request.Verify(x => x.SendJsonAsync("/auth/admin/users/query", It.Is(o => ReferenceEquals(o, query))), Times.Once); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs new file mode 100644 index 00000000..13bc71f7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthFlowClientTests.cs @@ -0,0 +1,818 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using FluentAssertions; +using Microsoft.Extensions.Options; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthFlowClientTests : UAuthClientTestBase +{ + private readonly Mock _mockRequest = new(); + + private UAuthFlowClient CreateClient(Mock? requestMock = null) + { + var request = requestMock ?? new Mock(); + + var events = new Mock(); + var deviceProvider = new Mock(); + var deviceIdProvider = new Mock(); + var returnUrlProvider = new Mock(); + + deviceIdProvider + .Setup(x => x.GetOrCreateAsync(It.IsAny())) + .Returns(new ValueTask( + DeviceId.Create("device-1234567890123456"))); + + returnUrlProvider + .Setup(x => x.GetCurrentUrl()) + .Returns("/home"); + + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth", + Login = "/login", + TryLogin = "/try-login" + }, + Login = new UAuthClientLoginFlowOptions + { + AllowCredentialPost = true + } + }); + + var diagnostics = new UAuthClientDiagnostics(); + + return new UAuthFlowClient( + request.Object, + events.Object, + deviceProvider.Object, + returnUrlProvider.Object, + options, + diagnostics); + } + + private static UAuthTransportResult TryLoginResponse(bool success, AuthFailureReason? reason = null) + { + return new UAuthTransportResult + { + Status = 200, + Body = JsonSerializer.SerializeToElement(new TryLoginResult + { + Success = success, + Reason = reason + }) + }; + } + + [Fact] + public async Task TryLogin_Should_Call_TryLogin_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(TryLoginResponse(true)); + + var client = CreateClient(mock); + await client.TryLoginAsync(new LoginRequest { Identifier = "admin", Secret = "admin" }, UAuthSubmitMode.TryOnly); + + mock.Verify(x => x.SendJsonAsync("/auth/try-login", It.IsAny()), Times.Once); + } + + [Fact] + public async Task TryLogin_Should_Return_Success() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(TryLoginResponse(true)); + + var client = CreateClient(mock); + var result = await client.TryLoginAsync(new LoginRequest { Identifier = "admin", Secret = "admin" }, UAuthSubmitMode.TryOnly); + + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task TryLogin_Should_Return_Failure() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(TryLoginResponse(false, AuthFailureReason.InvalidCredentials)); + + var client = CreateClient(mock); + var result = await client.TryLoginAsync(new LoginRequest { Identifier = "admin", Secret = "wrong" }, UAuthSubmitMode.TryOnly); + + result.Success.Should().BeFalse(); + result.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + } + + [Fact] + public async Task TryLogin_Should_Throw_When_Body_Null() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 200, + Body = null + }); + + var client = CreateClient(mock); + Func act = async () => await client.TryLoginAsync(new LoginRequest(), UAuthSubmitMode.TryOnly); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TryLogin_Should_Throw_When_Invalid_Json() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 200, + Body = JsonDocument.Parse("\"invalid\"").RootElement + }); + + var client = CreateClient(mock); + Func act = async () => await client.TryLoginAsync(new LoginRequest(), UAuthSubmitMode.TryOnly); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task TryLogin_DirectCommit_Should_Navigate() + { + var mock = new Mock(); + + mock.Setup(x => x.NavigateAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var client = CreateClient(mock); + + var request = new LoginRequest + { + Identifier = "admin", + Secret = "admin" + }; + + var result = await client.TryLoginAsync(request, UAuthSubmitMode.DirectCommit); + result.Success.Should().BeTrue(); + + mock.Verify(x => x.NavigateAsync("/auth/login", + It.Is>(d => d["Identifier"] == "admin" && d["Secret"] == "admin"), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task TryLogin_TryAndCommit_Should_Call_TryAndCommit() + { + var mock = new Mock(); + + mock.Setup(x => x.TryAndCommitAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TryLoginResult { Success = true }); + + var client = CreateClient(mock); + var result = await client.TryLoginAsync(new LoginRequest(), UAuthSubmitMode.TryAndCommit); + + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task TryLogin_Should_Throw_When_CredentialPost_Disabled() + { + var options = Options.Create(new UAuthClientOptions + { + Login = new UAuthClientLoginFlowOptions + { + AllowCredentialPost = false + } + }); + + var mock = new Mock(); + + var client = new UAuthFlowClient( + mock.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + options, + new UAuthClientDiagnostics()); + + Func act = async () => await client.TryLoginAsync(new LoginRequest(), UAuthSubmitMode.TryOnly); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_Should_Return_ReauthRequired_On_401() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 401 + }); + + var client = CreateClient(mock); + var result = await client.RefreshAsync(); + + result.IsSuccess.Should().BeFalse(); + result.Outcome.Should().Be(RefreshOutcome.ReauthRequired); + } + + [Fact] + public async Task Refresh_Should_Return_Success() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + RefreshOutcome = "success" + }); + + var client = CreateClient(mock); + + var result = await client.RefreshAsync(); + + result.IsSuccess.Should().BeTrue(); + result.Outcome.Should().Be(RefreshOutcome.Success); + } + + [Fact] + public async Task Validate_Should_Return_Result() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 200, + Body = JsonSerializer.SerializeToElement(new AuthValidationResult + { + State = SessionState.Active + }) + }); + + var client = CreateClient(mock); + var result = await client.ValidateAsync(); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task Login_Should_Navigate_To_Login_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.NavigateAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var client = CreateFlowClient(mock); + + await client.LoginAsync(new LoginRequest + { + Identifier = "user", + Secret = "pass" + }); + + mock.Verify(x => x.NavigateAsync("/auth/login", + It.Is>(d => + d["Identifier"] == "user" && + d["Secret"] == "pass"), + It.IsAny()), Times.Once); + } + + [Fact] + public async Task Logout_Should_Navigate_To_Logout() + { + var mock = new Mock(); + + mock.Setup(x => x.NavigateAsync( + It.IsAny(), + null, + It.IsAny())) + .Returns(Task.CompletedTask); + + var client = CreateFlowClient(mock); + await client.LogoutAsync(); + mock.Verify(x => x.NavigateAsync("/auth/logout", null, It.IsAny()), Times.Once); + } + + [Fact] + public async Task Refresh_Should_Not_Mark_Manual_When_Auto() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + RefreshOutcome = "success" + }); + + var client = CreateFlowClient(mock); + var result = await client.RefreshAsync(isAuto: true); + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Validate_Should_Publish_Event() + { + var request = new Mock(); + var events = new Mock(); + + request.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 200, + Body = JsonSerializer.SerializeToElement(new AuthValidationResult + { + State = SessionState.Active + }) + }); + + var client = CreateFlowClient(request, events); + await client.ValidateAsync(); + events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.ValidationCalled)), Times.Once); + } + + [Fact] + public async Task Validate_Should_Throw_On_Status_0() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 0 + }); + + var client = CreateFlowClient(mock); + Func act = async () => await client.ValidateAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LogoutMyDevice_Should_Publish_Event_When_CurrentChain() + { + var request = new Mock(); + var events = new Mock(); + + request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(new RevokeResult + { + CurrentChain = true + }) + }); + + var client = CreateFlowClient(request, events); + await client.LogoutMyDeviceAsync(new LogoutDeviceRequest() { ChainId = SessionChainId.New() }); + events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.LogoutVariant)), Times.Once); + } + + [Fact] + public async Task LogoutAllMyDevices_Should_Publish_Event_On_Success() + { + var request = new Mock(); + var events = new Mock(); + + request.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200 + }); + + var client = CreateFlowClient(request, events); + await client.LogoutAllMyDevicesAsync(); + events.Verify(x => x.PublishAsync(It.Is(e => e.Type == UAuthStateEvent.LogoutVariant)), Times.Once); + } + + [Fact] + public async Task BeginPkce_Should_Throw_When_Disabled() + { + var options = Options.Create(new UAuthClientOptions + { + Pkce = new UAuthClientPkceLoginFlowOptions + { + Enabled = false + } + }); + + var client = new UAuthFlowClient( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + options, + new UAuthClientDiagnostics()); + + Func act = async () => await client.BeginPkceAsync(); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task BeginPkce_Should_Call_Authorize_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(new PkceAuthorizeResponse + { + AuthorizationCode = "code123" + }) + }); + + var client = CreateFlowClient(mock); + await client.BeginPkceAsync(); + mock.Verify(x => x.SendFormAsync("/auth/pkce/authorize", It.Is>(d => + d.ContainsKey("code_challenge") && d["challenge_method"] == "S256"), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task BeginPkce_Should_Throw_When_AuthorizationCode_Missing() + { + var mock = new Mock(); + +#pragma warning disable CS8625 // Cannot convert null literal to non-nullable reference type. + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(new PkceAuthorizeResponse + { + AuthorizationCode = null + }) + }); +#pragma warning restore CS8625 + + var client = CreateFlowClient(mock); + + Func act = async () => await client.BeginPkceAsync(); + + await act.Should().ThrowAsync() + .WithMessage("*Invalid PKCE authorize response*"); + } + + [Fact] + public async Task BeginPkce_Should_AutoRedirect_When_Enabled() + { + var mock = new Mock(); + + mock.Setup(x => x.SendFormAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(new PkceAuthorizeResponse + { + AuthorizationCode = "code123" + }) + }); + + mock.Setup(x => x.NavigateAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth", + PkceAuthorize = "/pkce/authorize", + HubLoginPath = "/hub/login" + }, + Pkce = new UAuthClientPkceLoginFlowOptions + { + Enabled = true, + AutoRedirect = true + } + }); + + var client = new UAuthFlowClient( + mock.Object, + Mock.Of(), + Mock.Of(), + Mock.Of(), + options, + new UAuthClientDiagnostics()); + + await client.BeginPkceAsync(); + mock.Verify(x => x.NavigateAsync("/auth/hub/login", It.IsAny>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task TryCompletePkce_Should_Call_Try_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Status = 200, + Body = JsonSerializer.SerializeToElement(new TryPkceLoginResult + { + Success = true + }) + }); + + var client = CreateFlowClient(mock); + + var result = await client.TryCompletePkceLoginAsync( + new PkceCompleteRequest + { + AuthorizationCode = "code", + CodeVerifier = "verifier", + Identifier = "user", + Secret = "pass" + }, + UAuthSubmitMode.TryOnly); + + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task TryCompletePkce_Should_Call_TryAndCommit() + { + var mock = new Mock(); + + mock.Setup(x => x.TryAndCommitAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new TryPkceLoginResult { Success = true }); + + var client = CreateFlowClient(mock); + + var result = await client.TryCompletePkceLoginAsync( + new PkceCompleteRequest + { + AuthorizationCode = "code", + CodeVerifier = "verifier", + Identifier = "user", + Secret = "pass" + }, + UAuthSubmitMode.TryAndCommit); + + result.Success.Should().BeTrue(); + } + + [Fact] + public async Task TryCompletePkce_DirectCommit_Should_Navigate() + { + var mock = new Mock(); + + mock.Setup(x => x.NavigateAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var client = CreateFlowClient(mock); + + var result = await client.TryCompletePkceLoginAsync( + new PkceCompleteRequest + { + AuthorizationCode = "code", + CodeVerifier = "verifier", + Identifier = "user", + Secret = "pass" + }, + UAuthSubmitMode.DirectCommit); + + result.Success.Should().BeTrue(); + + mock.Verify(x => + x.NavigateAsync("/auth/pkce/complete", + It.IsAny>(), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task CompletePkce_Should_Navigate_With_Payload() + { + var mock = new Mock(); + + mock.Setup(x => x.NavigateAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns(Task.CompletedTask); + + var client = CreateFlowClient(mock); + + await client.CompletePkceLoginAsync(new PkceCompleteRequest + { + AuthorizationCode = "code", + CodeVerifier = "verifier", + ReturnUrl = "/home", + Identifier = "user", + Secret = "pass" + }); + + mock.Verify(x => + x.NavigateAsync("/auth/pkce/complete", + It.Is>(d => + d["authorization_code"] == "code" && + d["code_verifier"] == "verifier"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task CompletePkce_Should_Throw_When_Request_Null() + { + var client = CreateFlowClient(); + Func act = async () => await client.CompletePkceLoginAsync(null!); + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CompletePkce_Should_Throw_When_Pkce_Disabled() + { + var options = Options.Create(new UAuthClientOptions + { + Pkce = new UAuthClientPkceLoginFlowOptions + { + Enabled = false + } + }); + + var client = new UAuthFlowClient( + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + options, + new UAuthClientDiagnostics()); + + Func act = async () => await client.CompletePkceLoginAsync(new PkceCompleteRequest + { + AuthorizationCode = "code", + CodeVerifier = "verifier", + ReturnUrl = "/home", + Identifier = "user", + Secret = "pass"}); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task LogoutUserDevice_Should_Call_Admin_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(new RevokeResult()) + }); + + var client = CreateFlowClient(mock); + var userKey = UserKey.FromString("user-1"); + await client.LogoutUserDeviceAsync(userKey, new LogoutDeviceRequest() { ChainId = SessionChainId.New() }); + mock.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/logout-device", It.IsAny()), Times.Once); + } + + [Fact] + public async Task LogoutMyOtherDevices_Should_Call_Correct_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200 + }); + + var client = CreateFlowClient(mock); + await client.LogoutMyOtherDevicesAsync(); + mock.Verify(x => x.SendJsonAsync("/auth/me/logout-others", null), Times.Once); + } + + [Fact] + public async Task LogoutUserOtherDevices_Should_Call_Admin_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200 + }); + + var client = CreateFlowClient(mock); + var userKey = UserKey.FromString("user-1"); + await client.LogoutUserOtherDevicesAsync(userKey, new LogoutOtherDevicesRequest() { CurrentChainId = SessionChainId.New() }); + mock.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/logout-others", It.IsAny()), Times.Once); + } + + [Fact] + public async Task LogoutAllUserDevices_Should_Call_Admin_Endpoint() + { + var mock = new Mock(); + + mock.Setup(x => x.SendJsonAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new UAuthTransportResult + { + Ok = true, + Status = 200 + }); + + var client = CreateFlowClient(mock); + var userKey = UserKey.FromString("user-1"); + await client.LogoutAllUserDevicesAsync(userKey); + mock.Verify(x => x.SendJsonAsync($"/auth/admin/users/{userKey.Value}/logout-all", null), Times.Once); + } + + [Fact] + public void UAuthClient_Should_Expose_FlowClient() + { + var flow = Mock.Of(); + + var client = new UAuthClient( + flow, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()); + + client.Flows.Should().Be(flow); + } +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs new file mode 100644 index 00000000..301f457d --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthResultMapperTests.cs @@ -0,0 +1,136 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Errors; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthResultMapperTests +{ + [Fact] + public void FromJson_Should_Map_Success_Response() + { + var raw = new UAuthTransportResult + { + Status = 200, + Body = JsonDocument.Parse("{\"name\":\"test\"}").RootElement + }; + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeTrue(); + result.Value!.Name.Should().Be("test"); + } + + [Fact] + public void FromJson_Should_Handle_Empty_Body() + { + var raw = new UAuthTransportResult + { + Status = 204, + Body = null + }; + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public void FromJson_Should_Map_Problem_On_4xx() + { + var raw = new UAuthTransportResult + { + Status = 401, + Body = JsonDocument.Parse("{\"title\":\"Unauthorized\"}").RootElement + }; + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeFalse(); + result.Problem.Should().NotBeNull(); + } + + [Fact] + public void FromJson_Should_Throw_On_500() + { + var raw = new UAuthTransportResult + { + Status = 500 + }; + + Action act = () => UAuthResultMapper.FromJson(raw); + act.Should().Throw(); + } + + [Fact] + public void FromJson_Should_Throw_On_Invalid_Json() + { + var raw = new UAuthTransportResult + { + Status = 200, + Body = JsonDocument.Parse("\"invalid\"").RootElement + }; + + Action act = () => UAuthResultMapper.FromJson(raw); + act.Should().Throw(); + } + + [Fact] + public void FromJson_Should_Be_Case_Insensitive() + { + var raw = new UAuthTransportResult + { + Status = 200, + Body = JsonDocument.Parse("{\"NAME\":\"test\"}").RootElement + }; + + var result = UAuthResultMapper.FromJson(raw); + result.Value!.Name.Should().Be("test"); + } + + [Fact] + public void FromJson_Should_Handle_NullBody_With_Generic_Type() + { + var raw = new UAuthTransportResult + { + Status = 200, + Body = null + }; + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().BeNull(); + } + + [Fact] + public void FromJson_Should_Not_Throw_When_Problem_Invalid() + { + var raw = new UAuthTransportResult + { + Status = 400, + Body = JsonDocument.Parse("\"invalid\"").RootElement + }; + + var result = UAuthResultMapper.FromJson(raw); + + result.IsSuccess.Should().BeFalse(); + result.Problem.Should().BeNull(); + } + + [Fact] + public void FromJson_Should_Throw_On_Status_Zero() + { + var raw = new UAuthTransportResult + { + Status = 0 + }; + + Action act = () => UAuthResultMapper.FromJson(raw); + act.Should().Throw(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs new file mode 100644 index 00000000..1ee40234 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateManagerTests.cs @@ -0,0 +1,99 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Authentication; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; +using Moq; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthStateManagerTests +{ + [Fact] + public async Task EnsureAsync_should_not_validate_when_authenticated_and_not_stale() + { + var snapshot = new AuthStateSnapshot + { + Identity = new AuthIdentitySnapshot + { + UserKey = UserKey.FromString("user"), + Tenant = TenantKey.Single + }, + Claims = ClaimsSnapshot.Empty + }; + + var flowClient = new Mock(); + flowClient + .Setup(x => x.ValidateAsync()) + .ReturnsAsync(new AuthValidationResult + { + State = SessionState.Active, + Snapshot = snapshot + }); + + var client = new Mock(); + client.Setup(x => x.Flows).Returns(flowClient.Object); + + var events = new Mock(); + var clock = new Mock(); + clock.Setup(x => x.UtcNow).Returns(DateTimeOffset.UtcNow); + + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); + + await manager.EnsureAsync(); + await manager.EnsureAsync(); + + flowClient.Verify(x => x.ValidateAsync(), Times.Once); + } + + [Fact] + public async Task EnsureAsync_should_deduplicate_concurrent_calls() + { + var flows = new Mock(); + + flows.Setup(x => x.ValidateAsync()) + .Returns(async () => + { + await Task.Delay(50); + return new AuthValidationResult { State = SessionState.Invalid }; + }); + + var client = new Mock(); + client.SetupGet(x => x.Flows).Returns(flows.Object); + + var events = new Mock(); + var clock = new Mock(); + + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); + + await Task.WhenAll( + manager.EnsureAsync(true), + manager.EnsureAsync(true), + manager.EnsureAsync(true) + ); + + flows.Verify(x => x.ValidateAsync(), Times.Once); + } + + [Fact] + public async Task EnsureAsync_invalid_should_clear_state() + { + var client = new Mock(); + var events = new Mock(); + var clock = new Mock(); + + client.Setup(x => x.Flows.ValidateAsync()) + .ReturnsAsync(new AuthValidationResult + { + State = SessionState.Invalid + }); + + var manager = new UAuthStateManager(client.Object, events.Object, clock.Object); + await manager.EnsureAsync(); + manager.State.IsAuthenticated.Should().BeFalse(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateTests.cs new file mode 100644 index 00000000..8bc728dd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/UAuthStateTests.cs @@ -0,0 +1,95 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using FluentAssertions; +using System.Security.Claims; +using Xunit; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthStateTests +{ + private static AuthStateSnapshot CreateSnapshot() + { + var identity = new AuthIdentitySnapshot + { + UserKey = UserKey.FromGuid(Guid.NewGuid()), + Tenant = TenantKey.FromInternal("__single__"), + PrimaryUserName = "admin", + AuthenticatedAt = DateTimeOffset.UtcNow + }; + + var claims = ClaimsSnapshot.From((ClaimTypes.Role, "Admin"), ("uauth:permission", "*")); + + return new AuthStateSnapshot + { + Identity = identity, + Claims = claims + }; + } + + [Fact] + public void Anonymous_should_not_be_authenticated() + { + var state = UAuthState.Anonymous(); + + state.IsAuthenticated.Should().BeFalse(); + state.Identity.Should().BeNull(); + } + + [Fact] + public void ApplySnapshot_should_set_identity_and_claims() + { + var state = UAuthState.Anonymous(); + var snapshot = CreateSnapshot(); + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + state.IsAuthenticated.Should().BeTrue(); + state.Identity.Should().NotBeNull(); + state.Claims.Should().BeEquivalentTo(snapshot.Claims); + state.IsStale.Should().BeFalse(); + } + + [Fact] + public void Clear_should_reset_state() + { + var state = UAuthState.Anonymous(); + var snapshot = CreateSnapshot(); + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + state.Clear(); + + state.IsAuthenticated.Should().BeFalse(); + state.Identity.Should().BeNull(); + state.Claims.Should().Be(ClaimsSnapshot.Empty); + } + + [Fact] + public void IsInRole_should_return_true_when_role_exists() + { + var state = UAuthState.Anonymous(); + var snapshot = CreateSnapshot(); + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + state.IsInRole("Admin").Should().BeTrue(); + state.IsInRole("User").Should().BeFalse(); + } + + [Fact] + public void ToClaimsPrincipal_should_map_identity_correctly() + { + var state = UAuthState.Anonymous(); + var snapshot = CreateSnapshot(); + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + var principal = state.ToClaimsPrincipal(); + + principal.Identity!.Name.Should().Be("admin"); + principal.FindFirst(ClaimTypes.NameIdentifier)!.Value.Should().Be(snapshot.Identity.UserKey.Value); + principal.IsInRole("Admin").Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj new file mode 100644 index 00000000..ee936bd7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/CodeBeam.UltimateAuth.Tests.Unit.csproj @@ -0,0 +1,61 @@ +๏ปฟ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs new file mode 100644 index 00000000..c053640b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/AuthSessionIdTests.cs @@ -0,0 +1,93 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class AuthSessionIdTests +{ + private const string ValidRaw = "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string AnotherValidRaw = "session-bbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + [Fact] + public void TryCreate_returns_false_for_null() + { + var result = AuthSessionId.TryCreate(null!, out var id); + + Assert.False(result); + Assert.Equal(default, id); + } + + [Fact] + public void TryCreate_returns_false_for_empty_string() + { + var result = AuthSessionId.TryCreate(string.Empty, out var id); + + Assert.False(result); + Assert.Equal(default, id); + } + + [Fact] + public void TryCreate_returns_false_for_short_value() + { + var result = AuthSessionId.TryCreate("too-short", out var id); + + Assert.False(result); + Assert.Equal(default, id); + } + + [Fact] + public void TryCreate_creates_id_for_valid_value() + { + var result = AuthSessionId.TryCreate(ValidRaw, out var id); + + Assert.True(result); + Assert.NotEqual(default, id); + Assert.Equal(ValidRaw, id.Value); + } + + [Fact] + public void Equality_is_value_based() + { + AuthSessionId.TryCreate(ValidRaw, out var id1); + AuthSessionId.TryCreate(ValidRaw, out var id2); + + Assert.Equal(id1, id2); + Assert.True(id1 == id2); + Assert.False(id1 != id2); + } + + [Fact] + public void Different_values_are_not_equal() + { + AuthSessionId.TryCreate(ValidRaw, out var id1); + AuthSessionId.TryCreate(AnotherValidRaw, out var id2); + + Assert.NotEqual(id1, id2); + Assert.True(id1 != id2); + } + + [Fact] + public void ToString_returns_raw_value() + { + AuthSessionId.TryCreate(ValidRaw, out var id); + + Assert.Equal(ValidRaw, id.ToString()); + } + + [Fact] + public void Implicit_string_conversion_returns_raw_value() + { + AuthSessionId.TryCreate(ValidRaw, out var id); + + string value = id; + + Assert.Equal(ValidRaw, value); + } + + [Fact] + public void Default_value_has_null_Value() + { + var id = default(AuthSessionId); + + Assert.Null(id.Value); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs new file mode 100644 index 00000000..e94a7109 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/ConfigurationGuardsTests.cs @@ -0,0 +1,166 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Core.Runtime; +using CodeBeam.UltimateAuth.Server.Extensions; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Core; + +public sealed class ConfigurationGuardsTests +{ + [Fact] + public void Default_No_Config_Passes() + { + var provider = Build(services => + { + services.AddUltimateAuth(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + }); + + var _ = provider.GetRequiredService>().Value; + } + + [Fact] + public void Direct_Config_With_Allow_Passes() + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.AllowDirectCoreConfiguration = true; + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + }); + + var options = provider.GetRequiredService>().Value; + + Assert.Equal(TimeSpan.FromMinutes(5), options.Session.IdleTimeout); + } + + [Fact] + public void Direct_Config_Without_Allow_Fails() + { + Assert.Throws(() => + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + }); + + var _ = provider.GetRequiredService>().Value; + }); + } + + [Fact] + public void Server_Without_Core_Config_Passes() + { + var provider = Build(services => + { + services.AddUltimateAuth(); + services.AddSingleton(); + }); + + var _ = provider.GetRequiredService>().Value; + } + + [Fact] + public void Server_With_Core_Config_Fails() + { + Assert.Throws(() => + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + + services.AddSingleton(); + }); + + var _ = provider.GetRequiredService>().Value; + }); + } + + [Fact] + public void Server_With_Core_Config_Even_With_Allow_Fails() + { + Assert.Throws(() => + { + var provider = Build(services => + { + services.AddUltimateAuth(o => + { + o.AllowDirectCoreConfiguration = true; + o.Session.IdleTimeout = TimeSpan.FromMinutes(5); + }); + + services.AddSingleton(); + }); + + var _ = provider.GetRequiredService>().Value; + }); + } + + [Fact] + public void Core_configuration_is_blocked_when_server_is_present() + { + var dict = new Dictionary + { + ["UltimateAuth:Core:Session:IdleTimeout"] = "00:05:00" + }; + + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + + var provider = Build(services => + { + services.AddSingleton(config); + services.AddUltimateAuth(); + services.AddUltimateAuthServer(); + }); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*Direct core configuration is not allowed*"); + } + + [Fact] + public void Core_configuration_is_allowed_when_server_is_not_present() + { + var dict = new Dictionary + { + ["UltimateAuth:Core:AllowDirectCoreConfiguration"] = "true", + ["UltimateAuth:Core:Session:IdleTimeout"] = "00:05:00" + }; + + var config = new ConfigurationBuilder().AddInMemoryCollection(dict).Build(); + + var provider = Build(services => + { + services.AddSingleton(config); + services.AddUltimateAuth(); + }); + + var options = provider.GetRequiredService>().Value; + options.Session.IdleTimeout.Should().Be(TimeSpan.FromMinutes(5)); + } + + private sealed class FakeServerMarker : IUAuthRuntimeMarker { } + + private static IServiceProvider Build(Action configure) + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + configure(services); + return services.BuildServiceProvider(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs new file mode 100644 index 00000000..f2c6ac31 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/OptionValidatorTests.cs @@ -0,0 +1,70 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Options; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class OptionValidatorTests +{ + [Fact] + public void Negative_max_failed_attempts_should_fail_validation() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = -1 + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Excessive_max_failed_attempts_should_fail_validation() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = 1000 + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Lockout_enabled_without_duration_should_fail() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = 3, + LockoutDuration = TimeSpan.Zero + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Lockout_disabled_should_allow_zero_duration() + { + var options = new UAuthLoginOptions + { + MaxFailedAttempts = 0, + LockoutDuration = TimeSpan.Zero + }; + + var validator = new UAuthLoginOptionsValidator(); + + var result = validator.Validate(null, options); + + result.Succeeded.Should().BeTrue(); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs new file mode 100644 index 00000000..18f513ac --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/RefreshTokenValidatorTests.cs @@ -0,0 +1,233 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Tokens.InMemory; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class RefreshTokenValidatorTests +{ + private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + + private static UAuthRefreshTokenValidator CreateValidator(InMemoryRefreshTokenStoreFactory factory) + { + return new UAuthRefreshTokenValidator(factory, CreateHasher()); + } + + private static ITokenHasher CreateHasher() + { + return new HmacSha256TokenHasher(Encoding.UTF8.GetBytes("unit-test-secret-key")); + } + + [Fact] + public async Task Invalid_When_Token_Not_Found() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var validator = CreateValidator(factory); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = "non-existing", + Now = DateTimeOffset.UtcNow, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Reuse_Detected_When_Token_is_Revoked() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var hasher = CreateHasher(); + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var rawToken = "refresh-token-1"; + var hash = hasher.Hash(rawToken); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-aaaaaaaaaaaaaaaaaaaaaa"), + SessionChainId.New(), + now.AddMinutes(-5), + now.AddMinutes(5)); + + var revoked = token.Revoke(now); + + await store.StoreAsync(revoked); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = rawToken, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } + + [Fact] + public async Task Invalid_When_Expected_Session_Id_Does_Not_Match() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var token = RefreshToken.Create( + TokenId.New(), + "hash-2", + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-1-bbbbbbbbbbbbbbbbbbbbbb"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = "hash-2", + ExpectedSessionId = TestIds.Session("session-2-cccccccccccccccccccccc"), + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Invalid_When_Token_Is_Expired() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var token = RefreshToken.Create( + TokenId.New(), + "expired-hash", + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-expired"), + SessionChainId.New(), + now.AddMinutes(-10), + now.AddMinutes(-1)); + + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = "expired-hash", + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Valid_When_Token_Is_Active() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var raw = "valid-token"; + var hash = CreateHasher().Hash(raw); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-valid"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = raw, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.True(result.IsValid); + Assert.False(result.IsReuseDetected); + } + + [Fact] + public async Task Reuse_Detected_When_Old_Token_Is_Reused_After_Rotation() + { + var factory = new InMemoryRefreshTokenStoreFactory(); + var store = factory.Create(TenantKey.Single); + + var validator = CreateValidator(factory); + + var now = DateTimeOffset.UtcNow; + + var raw = "token-1"; + var hash = CreateHasher().Hash(raw); + + var token = RefreshToken.Create( + TokenId.New(), + hash, + TenantKey.Single, + UserKey.FromString("user-1"), + TestIds.Session("session-rotate"), + SessionChainId.New(), + now, + now.AddMinutes(10)); + + await store.StoreAsync(token.Revoke(now, "new-hash")); + + var result = await validator.ValidateAsync( + new RefreshTokenValidationContext + { + Tenant = TenantKey.Single, + RefreshToken = raw, + Now = now, + Device = DeviceContext.Create(DeviceId.Create(ValidDeviceId), null, null, null, null, null), + }); + + Assert.False(result.IsValid); + Assert.True(result.IsReuseDetected); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs new file mode 100644 index 00000000..2219f194 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionChainTests.cs @@ -0,0 +1,150 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UAuthSessionChainTests +{ + private static AuthSessionId CreateSessionId(string seed) + { + var raw = seed.PadRight(32, 'x'); + AuthSessionId.TryCreate(raw, out var id); + return id; + } + + [Fact] + public void New_chain_has_expected_initial_state() + { + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + Assert.Equal(0, chain.RotationCount); + Assert.Null(chain.ActiveSessionId); + Assert.False(chain.IsRevoked); + } + + [Fact] + public void Rotating_chain_sets_active_session_and_increments_rotation() + { + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + var sessionId = CreateSessionId("s1"); + var rotated = chain.RotateSession(sessionId, DateTimeOffset.UtcNow); + + Assert.Equal(1, rotated.RotationCount); + Assert.Equal(sessionId, rotated.ActiveSessionId); + Assert.NotSame(chain, rotated); + } + + [Fact] + public void Multiple_rotations_increment_rotation_count() + { + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + var first = chain.RotateSession(CreateSessionId("s1"), DateTimeOffset.UtcNow); + var second = first.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); + + Assert.Equal(2, second.RotationCount); + Assert.Equal(CreateSessionId("s2"), second.ActiveSessionId); + } + + [Fact] + public void Revoked_chain_does_not_rotate() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + var revoked = chain.Revoke(now); + var rotated = revoked.RotateSession(CreateSessionId("s2"), DateTimeOffset.UtcNow); + + Assert.Same(revoked, rotated); + Assert.True(rotated.IsRevoked); + } + + [Fact] + public void Revoking_chain_sets_revocation_fields() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + var revoked = chain.Revoke(now); + + Assert.True(revoked.IsRevoked); + Assert.Equal(now, revoked.RevokedAt); + } + + [Fact] + public void Revoking_already_revoked_chain_is_idempotent() + { + var now = DateTimeOffset.UtcNow; + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + SessionRootId.New(), + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + securityVersion: 0 + ); + + var revoked1 = chain.Revoke(now); + var revoked2 = revoked1.Revoke(now.AddMinutes(1)); + + Assert.Same(revoked1, revoked2); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs new file mode 100644 index 00000000..c5a3cfb7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UAuthSessionTests.cs @@ -0,0 +1,59 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UAuthSessionTests +{ + private const string ValidRaw = "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + private const string ValidDeviceId = "deviceidshouldbelongandstrongenough!?1234567890"; + + [Fact] + public void Revoke_marks_session_as_revoked() + { + var now = DateTimeOffset.UtcNow; + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + var session = UAuthSession.Create( + sessionId: sessionId, + tenant: TenantKey.Single, + userKey: UserKey.FromString("user-1"), + chainId: SessionChainId.New(), + now, + now.AddMinutes(10), + 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + var revoked = session.Revoke(now); + + Assert.False(session.IsRevoked); + Assert.True(revoked.IsRevoked); + Assert.Equal(now, revoked.RevokedAt); + } + + [Fact] + public void Revoking_twice_returns_same_instance() + { + var now = DateTimeOffset.UtcNow; + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + var session = UAuthSession.Create( + sessionId, + TenantKey.Single, + UserKey.FromString("user-1"), + SessionChainId.New(), + now, + now.AddMinutes(10), + 0, + DeviceContext.Create(DeviceId.Create(ValidDeviceId)), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + var revoked1 = session.Revoke(now); + var revoked2 = revoked1.Revoke(now.AddMinutes(1)); + + Assert.Same(revoked1, revoked2); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs new file mode 100644 index 00000000..a887a0df --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Core/UserIdConverterTests.cs @@ -0,0 +1,101 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using System.Globalization; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class UserIdConverterTests +{ + [Fact] + public void UserKey_Roundtrip_Should_Preserve_Value() + { + var key = UserKey.New(); + var converter = new UAuthUserIdConverter(); + + var str = converter.ToCanonicalString(key); + var parsed = converter.FromString(str); + + Assert.Equal(key, parsed); + } + + [Fact] + public void Guid_Roundtrip_Should_Work() + { + var id = Guid.NewGuid(); + var converter = new UAuthUserIdConverter(); + + var str = converter.ToCanonicalString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void String_Roundtrip_Should_Work() + { + var id = "user_123"; + var converter = new UAuthUserIdConverter(); + + var str = converter.ToCanonicalString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void Int_Should_Use_Invariant_Culture() + { + var id = 1234; + var converter = new UAuthUserIdConverter(); + + var str = converter.ToCanonicalString(id); + + Assert.Equal(id.ToString(CultureInfo.InvariantCulture), str); + } + + [Fact] + public void Long_Roundtrip_Should_Work() + { + var id = 9_223_372_036_854_775_000L; + var converter = new UAuthUserIdConverter(); + + var str = converter.ToCanonicalString(id); + var parsed = converter.FromString(str); + + Assert.Equal(id, parsed); + } + + [Fact] + public void Double_UserId_Should_Throw() + { + var converter = new UAuthUserIdConverter(); + + Assert.ThrowsAny(() => converter.ToCanonicalString(12.34)); + } + + private sealed class CustomUserId + { + public string Value { get; set; } = "x"; + } + + [Fact] + public void Custom_UserId_Should_Fail() + { + var converter = new UAuthUserIdConverter(); + + Assert.ThrowsAny(() => converter.ToCanonicalString(new CustomUserId())); + } + + [Fact] + public void UserKey_Json_Serialization_Should_Be_String() + { + var key = UserKey.New(); + + var json = JsonSerializer.Serialize(key); + var roundtrip = JsonSerializer.Deserialize(json); + + Assert.Equal(key, roundtrip); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs new file mode 100644 index 00000000..56003bfb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ChangePasswordTests.cs @@ -0,0 +1,131 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ChangePasswordTests +{ + [Fact] + public async Task Change_password_with_correct_current_should_succeed() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + var result = await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Change_password_with_wrong_current_should_throw() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + Func act = async () => + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "wrong", + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Change_password_to_same_should_throw() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + Func act = async () => + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "user" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Change_password_should_increment_version() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + var before = await service.GetAllAsync(context); + var versionBefore = before.Credentials.Single().Version; + + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + var after = await service.GetAllAsync(context); + var versionAfter = after.Credentials.Single().Version; + + versionAfter.Should().Be(versionBefore + 1); + } + + [Fact] + public async Task Old_password_should_not_work_after_change() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var context = TestAccessContext.ForUser(TestUsers.User, UAuthActions.Credentials.ChangeSelf); + + await service.ChangeSecretAsync(context, + new ChangeCredentialRequest + { + CurrentSecret = "user", + NewSecret = "newpass123" + }); + + var oldLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "user" + }); + + oldLogin.IsSuccess.Should().BeFalse(); + + var newLogin = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "newpass123" + }); + + newLogin.IsSuccess.Should().BeTrue(); + } + + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs new file mode 100644 index 00000000..fced32ec --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Credentials/ResetPasswordTests.cs @@ -0,0 +1,245 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ResetPasswordTests +{ + [Fact] + public async Task Begin_reset_with_token_should_return_token() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.Token.Should().NotBeNull(); + result.Token!.Length.Should().BeGreaterThan(20); + result.ExpiresAt.Should().BeAfter(DateTimeOffset.UtcNow); + } + + [Fact] + public async Task Begin_reset_with_code_should_return_numeric_code() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code + }); + + result.Token.Should().NotBeNull(); + result.Token!.Should().MatchRegex("^[0-9]{6}$"); + } + + [Fact] + public async Task Begin_reset_for_unknown_user_should_not_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var context = TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous); + + var result = await service.BeginResetAsync(context, + new BeginResetCredentialRequest + { + Identifier = "unknown@test.com", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + result.Token.Should().BeNull(); + } + + [Fact] + public async Task Reset_password_with_valid_token_should_succeed() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + var result = await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task Reset_password_with_same_password_should_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "admin" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_lock_after_max_attempts() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Code + }); + + for (int i = 0; i < 3; i++) + { + try + { + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin!.Token == "000000" ? "000001" : "000000", + NewSecret = "newpass123" + }); + } + catch { } + } + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_be_single_use() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "anotherpass" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Reset_token_should_fail_if_expired() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetCredentialManagementService(); + var clock = runtime.Clock; + + var begin = await service.BeginResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.BeginResetAnonymous), + new BeginResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetCodeType = ResetCodeType.Token + }); + + clock.Advance(TimeSpan.FromMinutes(45)); + + Func act = async () => + await service.CompleteResetAsync( + TestAccessContext.WithAction(UAuthActions.Credentials.CompleteResetAnonymous), + new CompleteResetCredentialRequest + { + Identifier = "admin", + CredentialType = CredentialType.Password, + ResetToken = begin.Token!, + NewSecret = "newpass123" + }); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs new file mode 100644 index 00000000..35d5a069 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreAuthenticationStoreTests.cs @@ -0,0 +1,214 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authentication.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Security; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreAuthenticationStoreTests : EfCoreTestBase +{ + private static UAuthAuthenticationDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthAuthenticationDbContext(options)); + } + + [Fact] + public async Task Add_And_Get_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var state = AuthenticationSecurityState.CreateAccount( + tenant, + userKey); + + await store.AddAsync(state); + + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + + Assert.NotNull(result); + Assert.Equal(state.Id, result!.Id); + } + + [Fact] + public async Task Update_With_RegisterFailure_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); + + await using (var db1 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + await store.AddAsync(state); + } + + await using (var db2 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + + var updated = existing!.RegisterFailure( + DateTimeOffset.UtcNow, + threshold: 3, + lockoutDuration: TimeSpan.FromMinutes(5)); + + await store.UpdateAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + + Assert.Equal(1, result!.SecurityVersion); + Assert.Equal(1, result.FailedAttempts); + } + } + + [Fact] + public async Task Update_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); + await store.AddAsync(state); + var updated = state.RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5)); + + await Assert.ThrowsAsync(() => store.UpdateAsync(updated, expectedVersion: 999)); + } + + [Fact] + public async Task RegisterSuccess_Should_Clear_Failures() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var state = AuthenticationSecurityState.CreateAccount(tenant, userKey) + .RegisterFailure(DateTimeOffset.UtcNow, 3, TimeSpan.FromMinutes(5)); + + await using (var db1 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + await store.AddAsync(state); + } + + await using (var db2 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var updated = existing!.RegisterSuccess(); + await store.UpdateAsync(updated, expectedVersion: 1); + } + + await using (var db3 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + + Assert.Equal(0, result!.FailedAttempts); + Assert.Null(result.LockedUntil); + } + } + + [Fact] + public async Task BeginReset_And_Consume_Should_Work() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); + + await using (var db1 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db1, new TenantContext(tenant)); + await store.AddAsync(state); + } + + DateTimeOffset now = DateTimeOffset.UtcNow; + + await using (var db2 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db2, new TenantContext(tenant)); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var updated = existing!.BeginReset("hash", now, TimeSpan.FromMinutes(10)); + await store.UpdateAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db3, new TenantContext(tenant)); + var existing = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + var consumed = existing!.ConsumeReset(DateTimeOffset.UtcNow); + await store.UpdateAsync(consumed, expectedVersion: 1); + } + + await using (var db4 = CreateDb(connection)) + { + var store = new EfCoreAuthenticationSecurityStateStore(db4, new TenantContext(tenant)); + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + Assert.NotNull(result!.ResetConsumedAt); + } + } + + [Fact] + public async Task Delete_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var state = AuthenticationSecurityState.CreateAccount(tenant, userKey); + + await store.AddAsync(state); + + await store.DeleteAsync(userKey, AuthenticationSecurityScope.Account, null); + + var result = await store.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + + Assert.Null(result); + } + + [Fact] + public async Task Should_Not_See_Data_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + + var store1 = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreAuthenticationSecurityStateStore(db, new TenantContext(tenant2)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var state = AuthenticationSecurityState.CreateAccount(tenant1, userKey); + await store1.AddAsync(state); + var result = await store2.GetAsync(userKey, AuthenticationSecurityScope.Account, null); + + Assert.Null(result); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs new file mode 100644 index 00000000..23e651d1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreCredentialStoreTests.cs @@ -0,0 +1,294 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Credentials.Contracts; +using CodeBeam.UltimateAuth.Credentials.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreCredentialStoreTests : EfCoreTestBase +{ + private static UAuthCredentialDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthCredentialDbContext(options)); + } + + [Fact] + public async Task Add_And_Get_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await store.AddAsync(credential); + + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(result); + Assert.Equal("hash", result!.SecretHash); + } + + [Fact] + public async Task Exists_Should_Return_True_When_Exists() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await store.AddAsync(credential); + var exists = await store.ExistsAsync(new CredentialKey(tenant, credential.Id)); + + Assert.True(exists); + } + + [Fact] + public async Task Save_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + await store1.AddAsync(credential); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); + var updated = existing!.ChangeSecret("new_hash", DateTimeOffset.UtcNow); + await store2.SaveAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); + var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); + + Assert.Equal(1, result!.Version); + Assert.Equal("new_hash", result.SecretHash); + } + } + + [Fact] + public async Task Save_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + await store1.AddAsync(credential); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); + + var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); + var updated = existing!.ChangeSecret("new_hash", DateTimeOffset.UtcNow); + + await Assert.ThrowsAsync(() => + store2.SaveAsync(updated, expectedVersion: 999)); + } + } + + [Fact] + public async Task Should_Not_See_Data_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + + var store1 = new EfCorePasswordCredentialStore(db, new TenantContext(tenant1)); + var store2 = new EfCorePasswordCredentialStore(db, new TenantContext(tenant2)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant1, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await store1.AddAsync(credential); + var result = await store2.GetAsync(new CredentialKey(tenant2, credential.Id)); + + Assert.Null(result); + } + + [Fact] + public async Task Soft_Delete_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCorePasswordCredentialStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await store.AddAsync(credential); + + await store.DeleteAsync( + new CredentialKey(tenant, credential.Id), + expectedVersion: 0, + DeleteMode.Soft, + DateTimeOffset.UtcNow); + + var result = await store.GetAsync(new CredentialKey(tenant, credential.Id)); + + Assert.NotNull(result); + Assert.NotNull(result!.DeletedAt); + } + + [Fact] + public async Task Revoke_Should_Persist() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + await store1.AddAsync(credential); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); + var revoked = existing!.Revoke(DateTimeOffset.UtcNow); + await store2.SaveAsync(revoked, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); + var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); + + Assert.True(result!.IsRevoked); + } + } + + [Fact] + public async Task ChangeSecret_Should_Update_SecurityState() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var credential = PasswordCredential.Create( + Guid.NewGuid(), + tenant, + userKey, + "hash", + CredentialSecurityState.Active(), + new CredentialMetadata(), + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCorePasswordCredentialStore(db1, new TenantContext(tenant)); + await store1.AddAsync(credential); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCorePasswordCredentialStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new CredentialKey(tenant, credential.Id)); + var updated = existing!.ChangeSecret("new_hash", DateTimeOffset.UtcNow); + + await store2.SaveAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCorePasswordCredentialStore(db3, new TenantContext(tenant)); + var result = await store3.GetAsync(new CredentialKey(tenant, credential.Id)); + + Assert.Equal("new_hash", result!.SecretHash); + Assert.NotNull(result.UpdatedAt); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs new file mode 100644 index 00000000..8418e86c --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreRoleStoreTests.cs @@ -0,0 +1,253 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization; +using CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreRoleStoreTests : EfCoreTestBase +{ + private static UAuthAuthorizationDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthAuthorizationDbContext(options)); + } + + [Fact] + public async Task Add_And_Get_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + + var role = Role.Create( + null, + tenant, + "admin", + new[] { Permission.From("read"), Permission.From("write") }, + DateTimeOffset.UtcNow); + + await store.AddAsync(role); + + var result = await store.GetAsync(new RoleKey(tenant, role.Id)); + + Assert.NotNull(result); + Assert.Equal("admin", result!.Name); + Assert.Equal(2, result.Permissions.Count); + } + + [Fact] + public async Task Add_With_Duplicate_Name_Should_Throw() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + + var role1 = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); + var role2 = Role.Create(null, tenant, "ADMIN", null, DateTimeOffset.UtcNow); + + await store.AddAsync(role1); + + await Assert.ThrowsAsync(() => store.AddAsync(role2)); + } + + [Fact] + public async Task Save_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + RoleId roleId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); + roleId = role.Id; + await store.AddAsync(role); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var existing = await store.GetAsync(new RoleKey(tenant, roleId)); + var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); + await store.SaveAsync(updated, expectedVersion: 0); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var result = await store.GetAsync(new RoleKey(tenant, roleId)); + + Assert.Equal(1, result!.Version); + Assert.Equal("admin2", result.Name); + } + } + + [Fact] + public async Task Save_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + RoleId roleId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); + roleId = role.Id; + await store.AddAsync(role); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var existing = await store.GetAsync(new RoleKey(tenant, roleId)); + var updated = existing!.Rename("admin2", DateTimeOffset.UtcNow); + + await Assert.ThrowsAsync(() => store.SaveAsync(updated, expectedVersion: 999)); + } + } + + [Fact] + public async Task Rename_To_Existing_Name_Should_Throw() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + + RoleId role1Id; + RoleId role2Id; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var role1 = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); + var role2 = Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow); + role1Id = role1.Id; + role2Id = role2.Id; + await store.AddAsync(role1); + await store.AddAsync(role2); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var role = await store.GetAsync(new RoleKey(tenant, role2Id)); + var updated = role!.Rename("admin", DateTimeOffset.UtcNow); + + await Assert.ThrowsAsync(() => store.SaveAsync(updated, 0)); + } + } + + [Fact] + public async Task Save_Should_Replace_Permissions() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + RoleId roleId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + + var role = Role.Create( + null, + tenant, + "admin", + new[] { Permission.From(UAuthActions.Authorization.Roles.GetAdmin) }, + DateTimeOffset.UtcNow); + + roleId = role.Id; + + await store.AddAsync(role); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var existing = await store.GetAsync(new RoleKey(tenant, roleId)); + var updated = existing!.SetPermissions( + new[] + { + Permission.From(UAuthActions.Authorization.Roles.SetPermissionsAdmin) + }, + DateTimeOffset.UtcNow); + await store.SaveAsync(updated, 0); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var result = await store.GetAsync(new RoleKey(tenant, roleId)); + + Assert.Single(result!.Permissions); + Assert.Contains(result.Permissions, p => p.Value == UAuthActions.Authorization.Roles.SetPermissionsAdmin); + } + } + + [Fact] + public async Task Soft_Delete_Should_Work() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + RoleId roleId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var role = Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow); + roleId = role.Id; + await store.AddAsync(role); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + await store.DeleteAsync(new RoleKey(tenant, roleId), 0, DeleteMode.Soft, DateTimeOffset.UtcNow); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + var result = await store.GetAsync(new RoleKey(tenant, roleId)); + Assert.NotNull(result!.DeletedAt); + } + } + + [Fact] + public async Task Query_Should_Filter_And_Page() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreRoleStore(db, new TenantContext(tenant)); + + await store.AddAsync(Role.Create(null, tenant, "admin", null, DateTimeOffset.UtcNow)); + await store.AddAsync(Role.Create(null, tenant, "user", null, DateTimeOffset.UtcNow)); + await store.AddAsync(Role.Create(null, tenant, "guest", null, DateTimeOffset.UtcNow)); + + var result = await store.QueryAsync(new RoleQuery + { + Search = "us", + PageNumber = 1, + PageSize = 10 + }); + + Assert.Single(result.Items); + Assert.Equal("user", result.Items.First().Name); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs new file mode 100644 index 00000000..ac3d32c5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreSessionStoreTests.cs @@ -0,0 +1,876 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Data.Sqlite; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreSessionStoreTests : EfCoreTestBase +{ + private const string ValidRaw = "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + private static UAuthSessionDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthSessionDbContext(options)); + } + + [Fact] + public async Task Create_And_Get_Session_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var root = UAuthSessionRoot.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chain.ChainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + + var result = await store.GetSessionAsync(session.SessionId); + + Assert.NotNull(result); + Assert.Equal(session.SessionId, result!.SessionId); + } + + [Fact] + public async Task Session_Should_Persist_DeviceContext() + { + using var connection = CreateOpenConnection(); + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var device = DeviceContext.Create( + DeviceId.Create("1234567890123456"), + deviceType: "mobile", + platform: "ios", + operatingSystem: "ios", + browser: "safari", + ipAddress: "127.0.0.1"); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + device, + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chain.ChainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + device, + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var result = await store.GetSessionAsync(sessionId); + + Assert.NotNull(result); + Assert.NotNull(result!.Device.DeviceId); + Assert.Equal("mobile", result.Device.DeviceType); + } + } + + [Fact] + public async Task Session_Should_Persist_Claims_And_Metadata() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + var claims = ClaimsSnapshot.From( + (ClaimTypes.Role, "admin"), + (ClaimTypes.Role, "user"), + ("uauth:permission", "read"), + ("uauth:permission", "write")); + + var metadata = new SessionMetadata + { + AppVersion = "1.0.0", + Locale = "en-US", + CsrfToken = "csrf-token-123", + Custom = new Dictionary + { + ["theme"] = "dark", + ["feature_flag"] = true + } + }; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + claims, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chain.ChainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + claims, + metadata); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var result = await store.GetSessionAsync(sessionId); + + Assert.NotNull(result); + + Assert.True(result!.Claims.IsInRole("admin")); + Assert.True(result.Claims.HasPermission("read")); + Assert.Equal(2, result.Claims.Roles.Count); + + Assert.Equal("1.0.0", result.Metadata.AppVersion); + Assert.Equal("en-US", result.Metadata.Locale); + Assert.Equal("csrf-token-123", result.Metadata.CsrfToken); + + Assert.NotNull(result.Metadata.Custom); + Assert.Equal("dark", result.Metadata.Custom!["theme"].ToString()); + } + } + + [Fact] + public async Task Revoke_Session_Should_Work() + { + using var connection = CreateOpenConnection(); + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chain.ChainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + + var revoked = await store.RevokeSessionAsync(sessionId, DateTimeOffset.UtcNow); + + Assert.True(revoked); + } + } + + [Fact] + public async Task Should_Not_See_Session_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + await using (var db = CreateDb(connection)) + { + var store1 = new EfCoreSessionStore(db, new TenantContext(tenant1)); + + var root = UAuthSessionRoot.Create(tenant1, userKey, DateTimeOffset.UtcNow); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant1, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant1, + userKey, + chain.ChainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store1.ExecuteAsync(async ct => + { + await store1.CreateRootAsync(root, ct); + await store1.CreateChainAsync(chain, ct); + await store1.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store2 = new EfCoreSessionStore(db, new TenantContext(tenant2)); + + var result = await store2.GetSessionAsync(sessionId); + + Assert.Null(result); + } + } + + [Fact] + public async Task ExecuteAsync_Should_Rollback_On_Error() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await Assert.ThrowsAsync(async () => + { + await store.ExecuteAsync(async ct => + { + throw new InvalidOperationException("boom"); + }); + }); + } + } + + [Fact] + public async Task GetSessionsByChain_Should_Return_Sessions() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + SessionChainId chainId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + chainId = SessionChainId.New(); + + var chain = UAuthSessionChain.Create( + chainId, + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var sessions = await store.GetSessionsByChainAsync(chainId); + Assert.Single(sessions); + } + } + + [Fact] + public async Task ExecuteAsync_Should_Commit_Multiple_Operations() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await store.ExecuteAsync(async ct => + { + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + var chain = UAuthSessionChain.Create( + SessionChainId.New(), + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chain.ChainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var result = await store.GetSessionAsync(sessionId); + + Assert.NotNull(result); + } + } + + [Fact] + public async Task ExecuteAsync_Should_Rollback_All_On_Failure() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await Assert.ThrowsAsync(async () => + { + await store.ExecuteAsync(async ct => + { + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + await store.CreateRootAsync(root, ct); + + // ๐Ÿ’ฅ simulate failure + throw new InvalidOperationException("boom"); + }); + }); + } + + await using (var db = CreateDb(connection)) + { + var count = db.Roots.Count(); + Assert.Equal(0, count); + } + } + + [Fact] + public async Task RevokeChainCascade_Should_Revoke_All_Sessions() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + SessionChainId chainId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + chainId = SessionChainId.New(); + + var chain = UAuthSessionChain.Create( + chainId, + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await store.ExecuteAsync(async ct => + { + await store.RevokeChainCascadeAsync(chainId, DateTimeOffset.UtcNow, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var sessions = await store.GetSessionsByChainAsync(chainId); + + Assert.All(sessions, s => Assert.True(s.IsRevoked)); + } + } + + [Fact] + public async Task SetActiveSession_Should_Work() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + SessionChainId chainId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + + chainId = SessionChainId.New(); + + var chain = UAuthSessionChain.Create( + chainId, + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.SetActiveSessionIdAsync(chainId, sessionId); + }); + + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var active = await store.GetActiveSessionIdAsync(chainId); + + Assert.Equal(sessionId, active); + } + } + + [Fact] + public async Task Query_Should_Not_Use_Domain_Computed_Properties() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var ex = await Record.ExceptionAsync(async () => + { + db.Sessions + .Where(x => x.RevokedAt == null) + .ToList(); + }); + + Assert.Null(ex); + } + + [Fact] + public async Task SaveSession_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + SessionChainId chainId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + chainId = SessionChainId.New(); + + var chain = UAuthSessionChain.Create( + chainId, + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await store.ExecuteAsync(async ct => + { + var existing = await store.GetSessionAsync(sessionId, ct); + var updated = existing!.Revoke(DateTimeOffset.UtcNow); + + await store.SaveSessionAsync(updated, expectedVersion: 0, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var result = await store.GetSessionAsync(sessionId); + + Assert.Equal(1, result!.Version); + } + } + + [Fact] + public async Task SaveSession_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + SessionChainId chainId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + chainId = SessionChainId.New(); + + var chain = UAuthSessionChain.Create( + chainId, + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + var session = UAuthSession.Create( + sessionId, + tenant, + userKey, + chainId, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1), + 0, + TestDevice.Default(), + ClaimsSnapshot.Empty, + SessionMetadata.Empty); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + await store.CreateSessionAsync(session, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await Assert.ThrowsAsync(async () => + { + await store.ExecuteAsync(async ct => + { + var existing = await store.GetSessionAsync(sessionId, ct); + var updated = existing!.Revoke(DateTimeOffset.UtcNow); + + await store.SaveSessionAsync(updated, expectedVersion: 999, ct); + }); + }); + } + } + + [Fact] + public async Task SaveChain_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + SessionChainId chainId; + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create(tenant, userKey, DateTimeOffset.UtcNow); + chainId = SessionChainId.New(); + + var chain = UAuthSessionChain.Create( + chainId, + root.RootId, + tenant, + userKey, + DateTimeOffset.UtcNow, + null, + TestDevice.Default(), + ClaimsSnapshot.Empty, + 0); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + await store.CreateChainAsync(chain, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await store.ExecuteAsync(async ct => + { + var existing = await store.GetChainAsync(chainId, ct); + var updated = existing!.Revoke(DateTimeOffset.UtcNow); + + await store.SaveChainAsync(updated, expectedVersion: 0, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var result = await store.GetChainAsync(chainId); + + Assert.Equal(1, result!.Version); + } + } + + [Fact] + public async Task SaveRoot_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + var root = UAuthSessionRoot.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await store.ExecuteAsync(async ct => + { + await store.CreateRootAsync(root, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + + await store.ExecuteAsync(async ct => + { + var existing = await store.GetRootByUserAsync(userKey, ct); + var updated = existing!.Revoke(DateTimeOffset.UtcNow); + + await store.SaveRootAsync(updated, expectedVersion: 0, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreSessionStore(db, new TenantContext(tenant)); + var result = await store.GetRootByUserAsync(userKey); + + Assert.Equal(1, result!.Version); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs new file mode 100644 index 00000000..c7bd8179 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreTokenStoreTests.cs @@ -0,0 +1,122 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Tokens.EntityFrameworkCore; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreTokenStoreTests : EfCoreTestBase +{ + private const string ValidRaw = "session-aaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + private static UAuthTokenDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthTokenDbContext(options)); + } + + [Fact] + public async Task Store_And_Find_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + var token = RefreshToken.Create( + TokenId.From(Guid.NewGuid()), + "hash", + tenant, + UserKey.FromGuid(Guid.NewGuid()), + sessionId, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1) + ); + + await store.ExecuteAsync(async ct => + { + await store.StoreAsync(token, ct); + }); + + var result = await store.FindByHashAsync("hash"); + + Assert.NotNull(result); + Assert.Equal("hash", result!.TokenHash); + } + + [Fact] + public async Task Revoke_Should_Set_RevokedAt() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var tokenHash = "hash"; + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + + var token = RefreshToken.Create( + TokenId.From(Guid.NewGuid()), + "hash", + tenant, + UserKey.FromGuid(Guid.NewGuid()), + sessionId, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1) + ); + + await store.ExecuteAsync(async ct => + { + await store.StoreAsync(token, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + + await store.ExecuteAsync(async ct => + { + await store.RevokeAsync(tokenHash, DateTimeOffset.UtcNow, null, ct); + }); + } + + await using (var db = CreateDb(connection)) + { + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + var result = await store.FindByHashAsync(tokenHash); + + Assert.NotNull(result!.RevokedAt); + } + } + + [Fact] + public async Task Store_Outside_Transaction_Should_Throw() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreRefreshTokenStore(db, new TenantContext(tenant)); + AuthSessionId.TryCreate(ValidRaw, out var sessionId); + + var token = RefreshToken.Create( + TokenId.From(Guid.NewGuid()), + "hash", + tenant, + UserKey.FromGuid(Guid.NewGuid()), + sessionId, + null, + DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddHours(1) + ); + + await Assert.ThrowsAsync(() => store.StoreAsync(token)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs new file mode 100644 index 00000000..4fd806af --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserIdentifierStoreTests.cs @@ -0,0 +1,331 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreUserIdentifierStoreTests : EfCoreTestBase +{ + private static UAuthUserDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthUserDbContext(options)); + } + + [Fact] + public async Task Add_And_Get_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + var tenant = TenantKeys.Single; + var store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var identifier = UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + "user", + "user", + DateTimeOffset.UtcNow, + isPrimary: true); + + await store.AddAsync(identifier); + var result = await store.GetAsync(identifier.Id); + + Assert.NotNull(result); + Assert.Equal(identifier.Id, result!.Id); + } + + [Fact] + public async Task Exists_Should_Return_True_When_Exists() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + var tenant = TenantKeys.Single; + var store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var identifier = UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + "user", + "user", + DateTimeOffset.UtcNow, + isPrimary: true); + + await store.AddAsync(identifier); + var exists = await store.ExistsAsync(identifier.Id); + + Assert.True(exists); + } + + [Fact] + public async Task Save_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + await using var db1 = CreateDb(connection); + var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); + + var identifier = UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + "user", + "user", + DateTimeOffset.UtcNow, + isPrimary: true); + + await store1.AddAsync(identifier); + + await using var db2 = CreateDb(connection); + var store2 = new EfCoreUserIdentifierStore(db2, new TenantContext(tenant)); + + var updated = identifier.SetPrimary(DateTimeOffset.UtcNow); + + await Assert.ThrowsAsync(() => + store2.SaveAsync(updated, expectedVersion: 999)); + } + + [Fact] + public async Task Save_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var identifier = UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + "user", + "user", + DateTimeOffset.UtcNow, + isPrimary: true); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCoreUserIdentifierStore(db1, new TenantContext(tenant)); + await store1.AddAsync(identifier); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCoreUserIdentifierStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(identifier.Id); + var updated = existing!.SetPrimary(DateTimeOffset.UtcNow); + await store2.SaveAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCoreUserIdentifierStore(db3, new TenantContext(tenant)); + var result = await store3.GetAsync(identifier.Id); + Assert.Equal(1, result!.Version); + } + } + + [Fact] + public async Task Should_Not_See_Data_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + var store1 = new EfCoreUserIdentifierStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserIdentifierStore(db, new TenantContext(tenant2)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var identifier = UserIdentifier.Create( + Guid.NewGuid(), + tenant1, + userKey, + UserIdentifierType.Username, + "user", + "user", + DateTimeOffset.UtcNow, + isPrimary: true); + + await store1.AddAsync(identifier); + var result = await store2.GetAsync(identifier.Id); + + Assert.Null(result); + } + + [Fact] + public async Task Soft_Delete_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + var tenant = TenantKeys.Single; + var store = new EfCoreUserIdentifierStore(db, new TenantContext(tenant)); + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var identifier = UserIdentifier.Create( + Guid.NewGuid(), + tenant, + userKey, + UserIdentifierType.Username, + "user", + "user", + DateTimeOffset.UtcNow, + isPrimary: true); + + await store.AddAsync(identifier); + await store.DeleteAsync(identifier.Id, 0, DeleteMode.Soft, DateTimeOffset.UtcNow); + var result = await store.GetAsync(identifier.Id); + + Assert.NotNull(result); + Assert.NotNull(result!.DeletedAt); + } + + [Fact] + public void ChangeValue_Should_Update_Value() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now); + + id.ChangeValue("new", "new", now); + + Assert.Equal("new", id.Value); + Assert.Equal("new", id.NormalizedValue); + Assert.Null(id.VerifiedAt); + } + + [Fact] + public void ChangeValue_SameValue_Should_Throw() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now); + + Assert.Throws(() => id.ChangeValue("user", "user", now)); + } + + [Fact] + public void SetPrimary_AlreadyPrimary_Should_NotChange() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now, + isPrimary: true); + + var result = id.SetPrimary(now); + + Assert.Same(id, result); + } + + [Fact] + public void UnsetPrimary_Should_Work() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now, + isPrimary: true); + + id.UnsetPrimary(now); + + Assert.False(id.IsPrimary); + } + + [Fact] + public void UnsetPrimary_NotPrimary_Should_Throw() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now); + + Assert.Throws(() => id.UnsetPrimary(now)); + } + + [Fact] + public void MarkVerified_Should_SetVerifiedAt() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now); + + id.MarkVerified(now); + + Assert.True(id.IsVerified); + } + + [Fact] + public void Deleted_Entity_Should_Not_Allow_Mutation() + { + var now = DateTimeOffset.UtcNow; + + var id = UserIdentifier.Create( + Guid.NewGuid(), + TenantKeys.Single, + UserKey.FromGuid(Guid.NewGuid()), + UserIdentifierType.Username, + "user", + "user", + now); + + id.MarkDeleted(now); + + Assert.Throws(() => + id.SetPrimary(now)); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs new file mode 100644 index 00000000..0c7c0333 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserLifecycleStoreTests.cs @@ -0,0 +1,227 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreUserLifecycleStoreTests : EfCoreTestBase +{ + private static UAuthUserDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthUserDbContext(options)); + } + + [Fact] + public async Task Add_And_Get_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await store.AddAsync(lifecycle); + + var result = await store.GetAsync(new UserLifecycleKey(tenant, userKey)); + + Assert.NotNull(result); + Assert.Equal(userKey, result!.UserKey); + Assert.Equal(UserStatus.Active, result.Status); + } + + [Fact] + public async Task Exists_Should_Return_True_When_Exists() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await store.AddAsync(lifecycle); + var exists = await store.ExistsAsync(new UserLifecycleKey(tenant, userKey)); + + Assert.True(exists); + } + + [Fact] + public async Task Save_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + await store1.AddAsync(lifecycle); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); + var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); + await store2.SaveAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); + var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); + + Assert.Equal(1, result!.Version); + Assert.Equal(UserStatus.Suspended, result.Status); + } + } + + [Fact] + public async Task Save_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + await store1.AddAsync(lifecycle); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); + var updated = existing!.ChangeStatus(DateTimeOffset.UtcNow, UserStatus.Suspended); + + await Assert.ThrowsAsync(() => + store2.SaveAsync(updated, expectedVersion: 999)); + } + } + + [Fact] + public async Task Should_Not_See_Data_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + + var store1 = new EfCoreUserLifecycleStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserLifecycleStore(db, new TenantContext(tenant2)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant1, + userKey, + DateTimeOffset.UtcNow); + + await store1.AddAsync(lifecycle); + + var result = await store2.GetAsync(new UserLifecycleKey(tenant2, userKey)); + + Assert.Null(result); + } + + [Fact] + public async Task Soft_Delete_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserLifecycleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await store.AddAsync(lifecycle); + + await store.DeleteAsync( + new UserLifecycleKey(tenant, userKey), + expectedVersion: 0, + DeleteMode.Soft, + DateTimeOffset.UtcNow); + + var result = await store.GetAsync(new UserLifecycleKey(tenant, userKey)); + + Assert.NotNull(result); + Assert.NotNull(result!.DeletedAt); + } + + [Fact] + public async Task Delete_Should_Increment_SecurityVersion() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var lifecycle = UserLifecycle.Create( + tenant, + userKey, + DateTimeOffset.UtcNow); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCoreUserLifecycleStore(db1, new TenantContext(tenant)); + await store1.AddAsync(lifecycle); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCoreUserLifecycleStore(db2, new TenantContext(tenant)); + + var existing = await store2.GetAsync(new UserLifecycleKey(tenant, userKey)); + var deleted = existing!.MarkDeleted(DateTimeOffset.UtcNow); + + await store2.SaveAsync(deleted, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCoreUserLifecycleStore(db3, new TenantContext(tenant)); + + var result = await store3.GetAsync(new UserLifecycleKey(tenant, userKey)); + + Assert.Equal(1, result!.SecurityVersion); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs new file mode 100644 index 00000000..87d84078 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserProfileStoreTests.cs @@ -0,0 +1,215 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreUserProfileStoreTests : EfCoreTestBase +{ + private static UAuthUserDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthUserDbContext(options)); + } + + [Fact] + public async Task Add_And_Get_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + DateTimeOffset.UtcNow, + displayName: "display", + firstName: "first", + lastName: "last" + ); + + await store.AddAsync(profile); + var result = await store.GetAsync(new UserProfileKey(tenant, userKey)); + + Assert.NotNull(result); + Assert.Equal(userKey, result!.UserKey); + } + + [Fact] + public async Task Exists_Should_Return_True_When_Exists() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + DateTimeOffset.UtcNow, + displayName: "display", + firstName: "first", + lastName: "last" + ); + + await store.AddAsync(profile); + var exists = await store.ExistsAsync(new UserProfileKey(tenant, userKey)); + + Assert.True(exists); + } + + [Fact] + public async Task Save_Should_Increment_Version() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + DateTimeOffset.UtcNow, + displayName: "display", + firstName: "first", + lastName: "last" + ); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCoreUserProfileStore(db1, new TenantContext(tenant)); + await store1.AddAsync(profile); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); + await store2.SaveAsync(updated, expectedVersion: 0); + } + + await using (var db3 = CreateDb(connection)) + { + var store3 = new EfCoreUserProfileStore(db3, new TenantContext(tenant)); + var result = await store3.GetAsync(new UserProfileKey(tenant, userKey)); + + Assert.Equal(1, result!.Version); + Assert.Equal("new", result.DisplayName); + } + } + + [Fact] + public async Task Save_With_Wrong_Version_Should_Throw() + { + using var connection = CreateOpenConnection(); + + var tenant = TenantKeys.Single; + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + DateTimeOffset.UtcNow, + displayName: "display", + firstName: "first", + lastName: "last" + ); + + await using (var db1 = CreateDb(connection)) + { + var store1 = new EfCoreUserProfileStore(db1, new TenantContext(tenant)); + await store1.AddAsync(profile); + } + + await using (var db2 = CreateDb(connection)) + { + var store2 = new EfCoreUserProfileStore(db2, new TenantContext(tenant)); + var existing = await store2.GetAsync(new UserProfileKey(tenant, userKey)); + var updated = existing!.UpdateName(existing.FirstName, existing.LastName, "new", DateTimeOffset.UtcNow); + + await Assert.ThrowsAsync(() => + store2.SaveAsync(updated, expectedVersion: 999)); + } + } + + [Fact] + public async Task Should_Not_See_Data_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + + var store1 = new EfCoreUserProfileStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserProfileStore(db, new TenantContext(tenant2)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile = UserProfile.Create( + Guid.NewGuid(), + tenant1, + userKey, + DateTimeOffset.UtcNow, + displayName: "display", + firstName: "first", + lastName: "last" + ); + + await store1.AddAsync(profile); + var result = await store2.GetAsync(new UserProfileKey(tenant2, userKey)); + + Assert.Null(result); + } + + [Fact] + public async Task Soft_Delete_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserProfileStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + + var profile = UserProfile.Create( + Guid.NewGuid(), + tenant, + userKey, + DateTimeOffset.UtcNow, + displayName: "display", + firstName: "first", + lastName: "last" + ); + + await store.AddAsync(profile); + + await store.DeleteAsync( + new UserProfileKey(tenant, userKey), + expectedVersion: 0, + DeleteMode.Soft, + DateTimeOffset.UtcNow); + + var result = await store.GetAsync(new UserProfileKey(tenant, userKey)); + + Assert.NotNull(result); + Assert.NotNull(result!.DeletedAt); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs new file mode 100644 index 00000000..e1062e34 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/EntityFrameworkCore/EfCoreUserRoleStoreTests.cs @@ -0,0 +1,152 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Contracts; +using CodeBeam.UltimateAuth.Authorization.EntityFrameworkCore; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using Microsoft.Data.Sqlite; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EfCoreUserRoleStoreTests : EfCoreTestBase +{ + private static UAuthAuthorizationDbContext CreateDb(SqliteConnection connection) + { + return CreateDbContext(connection, options => new UAuthAuthorizationDbContext(options)); + } + + [Fact] + public async Task Assign_And_GetAssignments_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var roleId = RoleId.New(); + + await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); + var result = await store.GetAssignmentsAsync(userKey); + + Assert.Single(result); + Assert.Equal(roleId, result.First().RoleId); + } + + [Fact] + public async Task Assign_Duplicate_Should_Throw() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var roleId = RoleId.New(); + + await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); + + await Assert.ThrowsAsync(() => store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow)); + } + + [Fact] + public async Task Remove_Should_Work() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var roleId = RoleId.New(); + + await store.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); + await store.RemoveAsync(userKey, roleId); + var result = await store.GetAssignmentsAsync(userKey); + + Assert.Empty(result); + } + + [Fact] + public async Task Remove_NonExisting_Should_Not_Throw() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var roleId = RoleId.New(); + + await store.RemoveAsync(userKey, roleId); // should not throw + } + + [Fact] + public async Task CountAssignments_Should_Return_Correct_Count() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + + var roleId = RoleId.New(); + + await store.AssignAsync(UserKey.FromGuid(Guid.NewGuid()), roleId, DateTimeOffset.UtcNow); + await store.AssignAsync(UserKey.FromGuid(Guid.NewGuid()), roleId, DateTimeOffset.UtcNow); + + var count = await store.CountAssignmentsAsync(roleId); + + Assert.Equal(2, count); + } + + [Fact] + public async Task RemoveAssignmentsByRole_Should_Remove_All() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant = TenantKeys.Single; + var store = new EfCoreUserRoleStore(db, new TenantContext(tenant)); + + var roleId = RoleId.New(); + + var user1 = UserKey.FromGuid(Guid.NewGuid()); + var user2 = UserKey.FromGuid(Guid.NewGuid()); + + await store.AssignAsync(user1, roleId, DateTimeOffset.UtcNow); + await store.AssignAsync(user2, roleId, DateTimeOffset.UtcNow); + + await store.RemoveAssignmentsByRoleAsync(roleId); + + var count = await store.CountAssignmentsAsync(roleId); + + Assert.Equal(0, count); + } + + [Fact] + public async Task Should_Not_See_Data_From_Other_Tenant() + { + using var connection = CreateOpenConnection(); + await using var db = CreateDb(connection); + + var tenant1 = TenantKeys.Single; + var tenant2 = TenantKey.FromInternal("tenant-2"); + + var store1 = new EfCoreUserRoleStore(db, new TenantContext(tenant1)); + var store2 = new EfCoreUserRoleStore(db, new TenantContext(tenant2)); + + var userKey = UserKey.FromGuid(Guid.NewGuid()); + var roleId = RoleId.New(); + + await store1.AssignAsync(userKey, roleId, DateTimeOffset.UtcNow); + + var result = await store2.GetAssignmentsAsync(userKey); + + Assert.Empty(result); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs new file mode 100644 index 00000000..c09a2df2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeFlowClient.cs @@ -0,0 +1,162 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Users.Contracts; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +internal sealed class FakeFlowClient : IFlowClient +{ + private readonly Queue _outcomes; + + public FakeFlowClient(params RefreshOutcome[] outcomes) + { + _outcomes = new Queue(outcomes); + } + + public Task BeginPkceAsync(bool navigateToHubLogin = true) + { + throw new NotImplementedException(); + } + + public Task BeginPkceAsync(string? returnUrl = null) + { + throw new NotImplementedException(); + } + + public Task CompletePkceLoginAsync(LoginRequest request) + { + throw new NotImplementedException(); + } + + public Task CompletePkceLoginAsync(PkceCompleteRequest request) + { + throw new NotImplementedException(); + } + + public Task GetCurrentPrincipalAsync() + { + throw new NotImplementedException(); + } + + public Task LoginAsync(LoginRequest request) + { + throw new NotImplementedException(); + } + + public Task LoginAsync(LoginRequest request, string? returnUrl) + { + throw new NotImplementedException(); + } + + public Task LogoutAllDevicesAdminAsync(UserKey userKey) + { + throw new NotImplementedException(); + } + + public Task LogoutAllDevicesSelfAsync() + { + throw new NotImplementedException(); + } + + public Task LogoutAllMyDevicesAsync() + { + throw new NotImplementedException(); + } + + public Task LogoutAllUserDevicesAsync(UserKey userKey) + { + throw new NotImplementedException(); + } + + public Task LogoutAsync() + { + throw new NotImplementedException(); + } + + public Task> LogoutDeviceAdminAsync(UserKey userKey, SessionChainId chainId) + { + throw new NotImplementedException(); + } + + public Task> LogoutDeviceAdminAsync(UserKey userKey, LogoutDeviceRequest request) + { + throw new NotImplementedException(); + } + + public Task> LogoutDeviceSelfAsync(LogoutDeviceRequest request) + { + throw new NotImplementedException(); + } + + public Task> LogoutMyDeviceAsync(LogoutDeviceRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutMyOtherDevicesAsync() + { + throw new NotImplementedException(); + } + + public Task LogoutOtherDevicesSelfAsync() + { + throw new NotImplementedException(); + } + + public Task> LogoutUserDeviceAsync(UserKey userKey, LogoutDeviceRequest request) + { + throw new NotImplementedException(); + } + + public Task LogoutUserOtherDevicesAsync(UserKey userKey, LogoutOtherDevicesRequest request) + { + throw new NotImplementedException(); + } + + public Task NavigateToHubLoginAsync(string authorizationCode, string codeVerifier, string? returnUrl = null) + { + throw new NotImplementedException(); + } + + public Task ReauthAsync() + { + throw new NotImplementedException(); + } + + public Task RefreshAsync(bool isAuto = false) + { + var outcome = _outcomes.Count > 0 + ? _outcomes.Dequeue() + : RefreshOutcome.Success; + + return Task.FromResult(new RefreshResult + { + IsSuccess = true, + Outcome = outcome + }); + } + + public Task TryCompletePkceLoginAsync(PkceCompleteRequest request, bool commitOnSuccess = false, CancellationToken ct = default) + { + throw new NotImplementedException(); + } + + public Task TryCompletePkceLoginAsync(PkceCompleteRequest request, UAuthSubmitMode mode) + { + throw new NotImplementedException(); + } + + public Task TryLoginAsync(LoginRequest request, UAuthSubmitMode mode, string? returnUrl = null) + { + throw new NotImplementedException(); + } + + public Task ValidateAsync() + { + throw new NotImplementedException(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs new file mode 100644 index 00000000..a0cd5b50 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Fake/FakeNavigationManager.cs @@ -0,0 +1,18 @@ +๏ปฟusing Microsoft.AspNetCore.Components; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +internal sealed class TestNavigationManager : NavigationManager +{ + public string? LastNavigatedTo { get; private set; } + + public TestNavigationManager() + { + Initialize("http://localhost/", "http://localhost/"); + } + + protected override void NavigateToCore(string uri, bool forceLoad) + { + LastNavigatedTo = uri; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs new file mode 100644 index 00000000..46de8a34 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/AuthFlowTestFactory.cs @@ -0,0 +1,37 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class AuthFlowTestFactory +{ + public static AuthFlowContext LoginSuccess(ReturnUrlInfo? returnUrlInfo = null, EffectiveRedirectResponse? redirect = null) + { + return new AuthFlowContext( + flowType: AuthFlowType.Login, + clientProfile: UAuthClientProfile.BlazorServer, + effectiveMode: UAuthMode.PureOpaque, + device: TestDevice.Default(), + tenantKey: TenantKey.Single, + isAuthenticated: true, + userKey: UserKey.New(), + session: null, + originalOptions: TestServerOptions.Default(), + effectiveOptions: TestServerOptions.Effective(), + response: new EffectiveAuthResponse( + sessionIdDelivery: CredentialResponseOptions.Disabled(GrantKind.Session), + accessTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.AccessToken), + refreshTokenDelivery: CredentialResponseOptions.Disabled(GrantKind.RefreshToken), + redirect: redirect ?? EffectiveRedirectResponse.Disabled + ), + primaryTokenKind: PrimaryTokenKind.Session, + returnUrlInfo: returnUrlInfo ?? ReturnUrlInfo.None() + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/EfCoreTestBase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/EfCoreTestBase.cs new file mode 100644 index 00000000..7cef91ab --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/EfCoreTestBase.cs @@ -0,0 +1,26 @@ +๏ปฟusing Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +public abstract class EfCoreTestBase +{ + protected SqliteConnection CreateOpenConnection() + { + var conn = new SqliteConnection("Filename=:memory:"); + conn.Open(); + return conn; + } + + protected static TDbContext CreateDbContext(SqliteConnection connection, Func, TDbContext> factory) where TDbContext : DbContext + { + var options = new DbContextOptionsBuilder() + .UseSqlite(connection) + .EnableSensitiveDataLogging() + .Options; + + var db = factory(options); + db.Database.EnsureCreated(); + return db; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs new file mode 100644 index 00000000..def622e2 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAccessContext.cs @@ -0,0 +1,42 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestAccessContext +{ + public static AccessContext WithAction(string action) + { + return new AccessContext( + actorUserKey: null, + actorTenant: TenantKey.Single, + isAuthenticated: false, + isSystemActor: false, + actorChainId: null, + resource: "test", + targetUserKey: null, + resourceTenant: TenantKey.Single, + action: action, + attributes: EmptyAttributes.Instance + ); + } + + public static AccessContext ForUser(UserKey userKey, string action, TenantKey? tenant = null) + { + var t = tenant ?? TenantKey.Single; + + return new AccessContext( + actorUserKey: userKey, + actorTenant: t, + isAuthenticated: true, + isSystemActor: false, + actorChainId: null, + resource: "identifier", + targetUserKey: userKey, + resourceTenant: t, + action: action, + attributes: EmptyAttributes.Instance + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs new file mode 100644 index 00000000..99e87dbb --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthModeResolver.cs @@ -0,0 +1,12 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestAuthModeResolver : IEffectiveAuthModeResolver +{ + public UAuthMode Resolve(UAuthClientProfile profile, AuthFlowType flowType) + => UAuthMode.PureOpaque; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs new file mode 100644 index 00000000..a37eb1d7 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthRuntime.cs @@ -0,0 +1,98 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Credentials.Reference; +using CodeBeam.UltimateAuth.InMemory; +using CodeBeam.UltimateAuth.Sample.Seed.Extensions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Users.Reference; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestAuthRuntime where TUserId : notnull +{ + public IServiceProvider Services { get; } + public TestClock Clock { get; } + + public TestAuthRuntime(Action? configureServer = null, Action? configureCore = null) + { + Clock = new TestClock(); + var services = new ServiceCollection(); + + services.AddLogging(); + + services.AddUltimateAuth(configureCore ?? (_ => { })); + services.AddUltimateAuthServer(options => + { + configureServer?.Invoke(options); + }); + + services.AddUltimateAuthSampleSeed(); + + services.AddSingleton(); + // InMemory plugins + services.AddUltimateAuthInMemory(); + + + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + + services.AddSingleton(configuration); + services.AddSingleton(Clock); + + Services = services.BuildServiceProvider(); + + using (var scope = Services.CreateScope()) + { + var seedRunner = scope.ServiceProvider.GetRequiredService(); + seedRunner.RunAsync(null).GetAwaiter().GetResult(); + } + + //Services = services.BuildServiceProvider(); + //Services.GetRequiredService().RunAsync(null).GetAwaiter().GetResult(); + } + + public ILoginOrchestrator GetLoginOrchestrator() + => Services.GetRequiredService(); + + public ValueTask CreateLoginFlowAsync(TenantKey? tenant = null) + { + var httpContext = TestHttpContext.Create(tenant); + return Services.GetRequiredService().CreateAsync(httpContext, AuthFlowType.Login); + } + + public IUserApplicationService GetUserApplicationService() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + public ICredentialManagementService GetCredentialManagementService() + { + var scope = Services.CreateScope(); + return scope.ServiceProvider.GetRequiredService(); + } + + public async Task LoginAsync(AuthFlowContext flow) + { + using var scope = Services.CreateScope(); + + var orchestrator = scope.ServiceProvider + .GetRequiredService(); + + return await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user" + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs new file mode 100644 index 00000000..8c087d34 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestAuthState.cs @@ -0,0 +1,83 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using System.Security.Claims; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +public static class TestAuthState +{ + public static UAuthState Anonymous() + => UAuthState.Anonymous(); + + public static UAuthState Authenticated( + string userId = "user-1", + params (string Type, string Value)[] claims) + { + var state = UAuthState.Anonymous(); + + var identity = new AuthIdentitySnapshot + { + UserKey = UserKey.FromString(userId), + Tenant = TenantKeys.Single, + SessionState = SessionState.Active, + UserStatus = UserStatus.Active + }; + + var snapshot = new AuthStateSnapshot + { + Identity = identity, + Claims = ClaimsSnapshot.From(claims) + }; + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + return state; + } + + public static UAuthState WithRoles(params string[] roles) + { + return Authenticated( + claims: roles.Select(r => (ClaimTypes.Role, r)).ToArray()); + } + + public static UAuthState WithPermissions(params string[] permissions) + { + return Authenticated( + claims: permissions.Select(p => ("uauth:permission", p)).ToArray()); + } + + public static UAuthState WithSession(SessionState sessionState) + { + var state = Authenticated(); + + var identity = state.Identity! with + { + SessionState = sessionState + }; + + var snapshot = new AuthStateSnapshot + { + Identity = identity, + Claims = state.Claims + }; + + state.ApplySnapshot(snapshot, DateTimeOffset.UtcNow); + + return state; + } + + public static UAuthState Full( + string userId, + string[] roles, + string[] permissions) + { + var claims = new List<(string, string)>(); + + claims.AddRange(roles.Select(r => (ClaimTypes.Role, r))); + claims.AddRange(permissions.Select(p => ("uauth:permission", p))); + + return Authenticated(userId, claims.ToArray()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs new file mode 100644 index 00000000..bdf7e960 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClientBaseAddressResolver.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Infrastructure; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestClientBaseAddressResolver +{ + public static ClientBaseAddressResolver Create() + { + var providers = new IClientBaseAddressProvider[] + { + new OriginHeaderBaseAddressProvider(), + new RefererHeaderBaseAddressProvider(), + new ConfiguredClientBaseAddressProvider(), + new RequestHostBaseAddressProvider(), + }; + + return new ClientBaseAddressResolver(providers); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs new file mode 100644 index 00000000..02256391 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestClock.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestClock : IClock +{ + private DateTimeOffset _utcNow; + + public TestClock(DateTimeOffset? initial = null) + { + _utcNow = initial ?? DateTimeOffset.UtcNow; + } + + public DateTimeOffset UtcNow => _utcNow; + + public void Advance(TimeSpan duration) + { + _utcNow = _utcNow.Add(duration); + } + + public void Set(DateTimeOffset time) + { + _utcNow = time; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs new file mode 100644 index 00000000..5e4c9238 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDevice.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestDevice +{ + public static DeviceContext Default() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-01"), null, null, null, null, null); + public static DeviceContext Alternative() => DeviceContext.Create(DeviceId.Create("test-device-000-000-000-000-alternative"), null, null, null, null, null); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDto.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDto.cs new file mode 100644 index 00000000..ab94f520 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestDto.cs @@ -0,0 +1,6 @@ +๏ปฟnamespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +public sealed class TestDto +{ + public string? Name { get; set; } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs new file mode 100644 index 00000000..38fdf1c1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHelpers.cs @@ -0,0 +1,17 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Abstractions; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestHelpers +{ + public static EffectiveServerOptionsProvider CreateEffectiveOptionsProvider(UAuthServerOptions options, IEffectiveAuthModeResolver? modeResolver = null) + { + return new EffectiveServerOptionsProvider(Options.Create(options), modeResolver ?? new EffectiveAuthModeResolver()); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs new file mode 100644 index 00000000..1e478c79 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContext.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestHttpContext +{ + public static HttpContext Create(TenantKey? tenant = null, UAuthClientProfile clientProfile = UAuthClientProfile.NotSpecified) + { + var ctx = new DefaultHttpContext(); + + var resolvedTenant = tenant ?? TenantKey.Single; + ctx.Items[UAuthConstants.HttpItems.TenantContextKey] = UAuthTenantContext.Resolved(resolvedTenant); + + ctx.Request.Headers["X-UDID"] = "test-device-000-000-000-000-01"; + ctx.Request.Headers["User-Agent"] = "UltimateAuth-Test"; + ctx.Request.Scheme = "https"; + ctx.Request.Host = new HostString("app.example.com"); + + return ctx; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs new file mode 100644 index 00000000..fa45dccd --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHttpContextExtensions.cs @@ -0,0 +1,38 @@ +๏ปฟusing Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestHttpContextExtensions +{ + public static HttpContext WithQuery(this HttpContext ctx, string key, string value) + { + ctx.Request.QueryString = QueryString.Create(key, value); + return ctx; + } + + public static HttpContext WithHeader(this HttpContext ctx, string name, string value) + { + ctx.Request.Headers[name] = value; + return ctx; + } + + public static HttpContext WithForm(this HttpContext ctx, IDictionary form) + { + ctx.Request.ContentType = "application/x-www-form-urlencoded"; + ctx.Request.Form = new FormCollection( + form.ToDictionary( + x => x.Key, + x => new Microsoft.Extensions.Primitives.StringValues(x.Value) + ) + ); + return ctx; + } + + public static HttpContext WithReturnUrl(this HttpContext ctx, string returnUrl) + { + return ctx.WithForm(new Dictionary + { + ["return_url"] = returnUrl + }); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHubFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHubFactory.cs new file mode 100644 index 00000000..e53df279 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestHubFactory.cs @@ -0,0 +1,25 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Flows; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestHubFactory +{ + public static HubFlowArtifact Create(PkceAuthorizationArtifact pkce) + { + var payload = new HubFlowPayload(); + payload.Set("authorization_code", pkce.AuthorizationCode.Value); + payload.Set("code_verifier", "test"); + + return new HubFlowArtifact( + HubSessionId.New(), + HubFlowType.Login, + pkce.Context.ClientProfile, + pkce.Context.Tenant, + TestDevice.Default(), + "/", + payload, + DateTimeOffset.UtcNow.AddMinutes(5) + ); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs new file mode 100644 index 00000000..1e4ad3d5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestIds.cs @@ -0,0 +1,19 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestIds +{ + public static AuthSessionId Session(string raw) + { + if (raw.Length < 32) + { + raw = raw.PadRight(32, 'x'); + } + + if (!AuthSessionId.TryCreate(raw, out var id)) + throw new InvalidOperationException($"Invalid test AuthSessionId: {raw}"); + + return id; + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs new file mode 100644 index 00000000..c6b363ec --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPasswordHasher.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestPasswordHasher : IUAuthPasswordHasher +{ + public string Hash(string password) => $"HASH::{password}"; + public bool Verify(string hashedPassword, string providedPassword) => hashedPassword == $"HASH::{providedPassword}"; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPkceFactory.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPkceFactory.cs new file mode 100644 index 00000000..eeafc7da --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestPkceFactory.cs @@ -0,0 +1,44 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Infrastructure; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Stores; +using System.Security.Cryptography; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestPkceFactory +{ + public static (PkceAuthorizationArtifact Artifact, string Verifier) Create( + UAuthClientProfile profile = UAuthClientProfile.BlazorWasm, + TenantKey? tenant = null) + { + tenant ??= TenantKeys.Single; + + var verifier = "test_verifier_123"; + + using var sha256 = SHA256.Create(); + var hash = sha256.ComputeHash(Encoding.ASCII.GetBytes(verifier)); + var challenge = Base64Url.Encode(hash); + + var key = AuthArtifactKey.New(); + + var snapshot = new PkceContextSnapshot( + clientProfile: profile, + tenant: (TenantKey)tenant, + redirectUri: "/", + device: TestDevice.Default() + ); + + var artifact = new PkceAuthorizationArtifact( + authorizationCode: key, + codeChallenge: challenge, + challengeMethod: PkceChallengeMethod.S256, + expiresAt: DateTimeOffset.UtcNow.AddMinutes(5), + context: snapshot + ); + + return (artifact, verifier); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs new file mode 100644 index 00000000..496c5873 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestRedirectResolver.cs @@ -0,0 +1,39 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using Microsoft.AspNetCore.Http; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal sealed class TestRedirectResolver : IAuthRedirectResolver +{ + private readonly AuthRedirectResolver _inner; + + private TestRedirectResolver(AuthRedirectResolver inner) + { + _inner = inner; + } + + public RedirectDecision ResolveSuccess(AuthFlowContext flow, HttpContext ctx) + => _inner.ResolveSuccess(flow, ctx); + + public RedirectDecision ResolveFailure(AuthFlowContext flow, HttpContext ctx, AuthFailureReason reason, LoginResult? result = null) + => _inner.ResolveFailure(flow, ctx, reason, result); + + public static TestRedirectResolver Create(IEnumerable? providers = null) + { + var baseProviders = providers?.ToList() ?? new List + { + new OriginHeaderBaseAddressProvider(), + new RefererHeaderBaseAddressProvider(), + new ConfiguredClientBaseAddressProvider(), + new RequestHostBaseAddressProvider() + }; + + var baseResolver = new ClientBaseAddressResolver(baseProviders); + var authResolver = new AuthRedirectResolver(baseResolver); + + return new TestRedirectResolver(authResolver); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs new file mode 100644 index 00000000..54f182c0 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestServerOptions.cs @@ -0,0 +1,24 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +internal static class TestServerOptions +{ + public static UAuthServerOptions Default() + => new() + { + Hub = + { + ClientBaseAddress = "https://app.example.com" + } + }; + + public static EffectiveUAuthServerOptions Effective(UAuthMode mode = UAuthMode.PureOpaque) + => new EffectiveUAuthServerOptions + { + Mode = mode, + Options = Default() + }; +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestUsers.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestUsers.cs new file mode 100644 index 00000000..ccd090ab --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/TestUsers.cs @@ -0,0 +1,9 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Domain; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +public static class TestUsers +{ + public static readonly UserKey Admin = UserKey.FromGuid(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")); + public static readonly UserKey User = UserKey.FromGuid(Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")); +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs new file mode 100644 index 00000000..05b99323 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Helpers/UAuthClientTestBase.cs @@ -0,0 +1,164 @@ +๏ปฟusing CodeBeam.UltimateAuth.Client; +using CodeBeam.UltimateAuth.Client.Abstractions; +using CodeBeam.UltimateAuth.Client.Contracts; +using CodeBeam.UltimateAuth.Client.Diagnostics; +using CodeBeam.UltimateAuth.Client.Events; +using CodeBeam.UltimateAuth.Client.Infrastructure; +using CodeBeam.UltimateAuth.Client.Options; +using CodeBeam.UltimateAuth.Client.Services; +using CodeBeam.UltimateAuth.Core.Domain; +using Microsoft.Extensions.Options; +using Moq; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +public abstract class UAuthClientTestBase +{ + protected readonly Mock Request = new(); + protected readonly Mock Events = new(); + + protected IUAuthClient CreateClient( + IUserClient? users = null, + ISessionClient? sessions = null, + ICredentialClient? credentials = null, + IAuthorizationClient? authorization = null, + IFlowClient? flows = null, + IUserIdentifierClient? identifiers = null) + { + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth" + } + }); + + return new UAuthClient( + flows ?? Mock.Of(), + sessions ?? new UAuthSessionClient(Request.Object, options, Events.Object), + users ?? new UAuthUserClient(Request.Object, Events.Object, options), + identifiers ?? Mock.Of(), + credentials ?? new UAuthCredentialClient(Request.Object, Events.Object, options), + authorization ?? new UAuthAuthorizationClient(Request.Object, Events.Object, options) + ); + } + + protected IFlowClient CreateFlowClient( + Mock? requestMock = null, + Mock? eventsMock = null) + { + var request = requestMock ?? new Mock(); + var events = eventsMock ?? new Mock(); + + var deviceProvider = new Mock(); + deviceProvider.Setup(x => x.GetAsync()) + .ReturnsAsync(DeviceContext.Create(DeviceId.Create("device-123456789123456789123456789123456789"), "web")); + + var returnUrlProvider = new Mock(); + returnUrlProvider.Setup(x => x.GetCurrentUrl()) + .Returns("/home"); + + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth", + Login = "/login", + TryLogin = "/try-login", + Logout = "/logout", + Refresh = "/refresh", + Validate = "/validate" + }, + Login = new UAuthClientLoginFlowOptions + { + AllowCredentialPost = true + }, + Pkce = new UAuthClientPkceLoginFlowOptions + { + Enabled = true + } + }); + + var diagnostics = new UAuthClientDiagnostics(); + + return new UAuthFlowClient( + request.Object, + events.Object, + deviceProvider.Object, + returnUrlProvider.Object, + options, + diagnostics); + } + + protected IUAuthClient CreateCredentialClient() + { + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth" + } + }); + + return new UAuthClient( + flows: Mock.Of(), + session: Mock.Of(), + users: Mock.Of(), + identifiers: Mock.Of(), + credentials: new UAuthCredentialClient(Request.Object, Events.Object, options), + authorization: Mock.Of()); + } + + protected IUAuthClient CreateUserClient() + { + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth" + } + }); + + return new UAuthClient( + flows: Mock.Of(), + session: Mock.Of(), + users: new UAuthUserClient(Request.Object, Events.Object, options), + identifiers: Mock.Of(), + credentials: Mock.Of(), + authorization: Mock.Of()); + } + + protected IUAuthClient CreateIdentifierClient() + { + var options = Options.Create(new UAuthClientOptions + { + Endpoints = new UAuthClientEndpointOptions + { + BasePath = "/auth" + } + }); + + return new UAuthClient( + flows: Mock.Of(), + session: Mock.Of(), + users: Mock.Of(), + identifiers: new UAuthUserIdentifierClient(Request.Object, Events.Object, options), + credentials: Mock.Of(), + authorization: Mock.Of()); + } + + protected static UAuthTransportResult Success() + => new() { Ok = true, Status = 200 }; + + protected static UAuthTransportResult Failure(int status = 400) + => new() { Ok = false, Status = status }; + + protected static UAuthTransportResult SuccessJson(T body) + => new() + { + Ok = true, + Status = 200, + Body = JsonSerializer.SerializeToElement(body) + }; +} \ No newline at end of file diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs new file mode 100644 index 00000000..b64b32db --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Policies/ActionTextTests.cs @@ -0,0 +1,60 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authorization.Policies; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ActionTextTests +{ + private readonly MustHavePermissionPolicy _policy = new(); + + [Theory] + [InlineData("users.profile.get.admin", true)] + [InlineData("users.delete.admin", true)] + [InlineData("sessions.revoke.admin", true)] + public void AppliesTo_ReturnsTrue_ForAdminScope(string action, bool expected) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("users.profile.get.self")] + [InlineData("users.profile.get")] + [InlineData("users.profile.get.anonymous")] + public void AppliesTo_ReturnsFalse_ForNonAdminScope(string action) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.False(result); + } + + [Fact] + public void AppliesTo_DoesNotMatch_Substrings() + { + var context = TestAccessContext.WithAction("users.profile.get.administrator"); + var result = _policy.AppliesTo(context); + Assert.False(result); + } + + [Fact] + public void AppliesTo_IsCaseInsensitive() + { + var context = TestAccessContext.WithAction("users.profile.get.ADMIN"); + var result = _policy.AppliesTo(context); + Assert.True(result); + } + + [Theory] + [InlineData("users.create.admin", true)] + [InlineData("users.create.self", false)] + [InlineData("users.create.anonymous", false)] + [InlineData("users.create", false)] + [InlineData("users.create.admin.extra", false)] + public void AppliesTo_AdminScopeDetection(string action, bool expected) + { + var context = TestAccessContext.WithAction(action); + var result = _policy.AppliesTo(context); + Assert.Equal(expected, result); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs new file mode 100644 index 00000000..506c90f1 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Security/Argon2PasswordHasherTest.cs @@ -0,0 +1,86 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Security.Argon2; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class Argon2PasswordHasherTests +{ + private Argon2PasswordHasher CreateHasher() + { + var options = Options.Create(new Argon2Options()); + return new Argon2PasswordHasher(options); + } + + [Fact] + public void Hash_ShouldReturn_NonEmptyString() + { + var hasher = CreateHasher(); + var result = hasher.Hash("password123"); + + Assert.False(string.IsNullOrWhiteSpace(result)); + Assert.Contains(".", result); + } + + [Fact] + public void Verify_ShouldReturn_True_ForValidPassword() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + var result = hasher.Verify(hash, "password123"); + + Assert.True(result); + } + + [Fact] + public void Verify_ShouldReturn_False_ForInvalidPassword() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + var result = hasher.Verify(hash, "wrong-password"); + + Assert.False(result); + } + + [Fact] + public void Verify_ShouldReturn_False_ForInvalidFormat() + { + var hasher = CreateHasher(); + var result = hasher.Verify("invalid-format", "password"); + + Assert.False(result); + } + + [Fact] + public void Hash_ShouldThrow_WhenPasswordIsEmpty() + { + var hasher = CreateHasher(); + Assert.Throws(() => hasher.Hash("")); + } + + [Fact] + public void Hash_ShouldProduce_DifferentHashes_ForSamePassword() + { + var hasher = CreateHasher(); + var hash1 = hasher.Hash("password123"); + var hash2 = hasher.Hash("password123"); + + Assert.NotEqual(hash1, hash2); + } + + [Fact] + public void Verify_ShouldUse_SameSalt_FromHash() + { + var hasher = CreateHasher(); + var hash = hasher.Hash("password123"); + + var parts = hash.Split('.'); + Assert.Equal(2, parts.Length); + + var salt1 = parts[0]; + var hash2 = hasher.Hash("password123"); + var salt2 = hash2.Split('.')[0]; + + Assert.NotEqual(salt1, salt2); // random salt doฤŸrulama + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs new file mode 100644 index 00000000..9141aa56 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ClientBaseAddressProviderTests.cs @@ -0,0 +1,64 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ClientBaseAddressProviderTests +{ + [Fact] + public void Resolve_Uses_Absolute_ReturnUrl() + { + var resolver = TestClientBaseAddressResolver.Create(); + var ctx = TestHttpContext.Create().WithReturnUrl("https://app.example.com/dashboard"); + + var options = new UAuthServerOptions(); + + var result = resolver.Resolve(ctx, options); + + result.Should().Be("https://app.example.com"); + } + + [Fact] + public void Resolve_Ignores_Relative_ReturnUrl() + { + var resolver = TestClientBaseAddressResolver.Create(); + + var ctx = TestHttpContext + .Create() + .WithReturnUrl("/dashboard"); + + var options = new UAuthServerOptions + { + Hub = { ClientBaseAddress = "https://fallback.example.com" } + }; + + var result = resolver.Resolve(ctx, options); + + result.Should().Be("https://fallback.example.com"); + } + + [Fact] + public void Resolve_Fails_When_Origin_Not_Allowed() + { + var resolver = TestClientBaseAddressResolver.Create(); + var ctx = TestHttpContext.Create().WithHeader("Origin", "https://evil.com"); + + var options = new UAuthServerOptions + { + Hub = + { + AllowedClientOrigins = new HashSet + { + "https://app.example.com" + } + } + }; + + Action act = () => resolver.Resolve(ctx, options); + + act.Should().Throw().WithMessage("*not allowed*"); + } + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs new file mode 100644 index 00000000..9b72cccf --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveAuthModeResolverTests.cs @@ -0,0 +1,22 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EffectiveAuthModeResolverTests +{ + private readonly EffectiveAuthModeResolver _resolver = new(); + + [Theory] + [InlineData(UAuthClientProfile.BlazorServer, UAuthMode.PureOpaque)] + [InlineData(UAuthClientProfile.BlazorWasm, UAuthMode.Hybrid)] + [InlineData(UAuthClientProfile.Maui, UAuthMode.Hybrid)] + [InlineData(UAuthClientProfile.Api, UAuthMode.PureJwt)] + public void Default_Mode_Is_Derived_From_ClientProfile(UAuthClientProfile profile, UAuthMode expected) + { + var mode = _resolver.Resolve(clientProfile: profile, flowType: AuthFlowType.Login); + Assert.Equal(expected, mode); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs new file mode 100644 index 00000000..f2d98b20 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/EffectiveServerOptionsProviderTests.cs @@ -0,0 +1,113 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class EffectiveServerOptionsProviderTests +{ + [Fact] + public void Original_Options_Are_Not_Mutated() + { + var baseOptions = new UAuthServerOptions(); + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = TestHttpContext.Create(); + + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + effective.Options.Token.AccessTokenLifetime = TimeSpan.FromSeconds(10); + + Assert.NotEqual(baseOptions.Token.AccessTokenLifetime, effective.Options.Token.AccessTokenLifetime); + } + + [Fact] + public void EffectiveMode_Is_Determined_By_ModeResolver() + { + var baseOptions = new UAuthServerOptions(); + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.Api); + + Assert.Equal(UAuthMode.PureJwt, effective.Mode); + } + + [Fact] + public void Mode_Defaults_Are_Applied_Before_Overrides() + { + var baseOptions = new UAuthServerOptions(); + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + + Assert.True(effective.Options.Session.SlidingExpiration); + Assert.NotNull(effective.Options.Session.IdleTimeout); + } + + [Fact] + public void ModeConfiguration_Overrides_Mode_Defaults() + { + var baseOptions = new UAuthServerOptions(); + + baseOptions.ConfigureMode(UAuthMode.PureOpaque, o => + { + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(1); + }); + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = TestHttpContext.Create(); + var effective = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + + Assert.Equal(TimeSpan.FromMinutes(1), effective.Options.Token.AccessTokenLifetime); + } + + [Fact] + public void Each_Call_Returns_New_EffectiveOptions_Instance() + { + var baseOptions = new UAuthServerOptions(); + + var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + var ctx = TestHttpContext.Create(); + + var first = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var second = provider.GetEffective(ctx, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + + Assert.NotSame(first.Options, second.Options); + } + + // TODO: Discuss and enable + //[Fact] + //public void FlowType_Is_Passed_To_ModeResolver() + //{ + // var baseOptions = new UAuthServerOptions + // { + // Mode = null + // }; + + // var provider = TestHelpers.CreateEffectiveOptionsProvider(baseOptions); + // var ctx = new DefaultHttpContext(); + + // var login = provider.GetEffective( + // ctx, + // AuthFlowType.Login, + // UAuthClientProfile.Api); + + // var api = provider.GetEffective( + // ctx, + // AuthFlowType.ApiAccess, + // UAuthClientProfile.Api); + + // Assert.NotEqual(login.Mode, api.Mode); + //} + +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs new file mode 100644 index 00000000..8aeb4392 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/LoginOrchestratorTests.cs @@ -0,0 +1,435 @@ +๏ปฟusing CodeBeam.UltimateAuth.Authentication.InMemory; +using CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Events; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class LoginOrchestratorTests +{ + [Fact] + public async Task Successful_login_should_return_success_result() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "user", + }); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Successful_login_should_create_session() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "user", + }); + + result.SessionId.Should().NotBeNull(); + } + + [Fact] + public async Task First_failed_login_should_record_attempt() + { + var runtime = new TestAuthRuntime(configureCore: o => + { + o.Login.MaxFailedAttempts = 3; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + var factory = runtime.Services.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(1); + } + + [Fact] + public async Task Successful_login_should_clear_failure_state() + { + var runtime = new TestAuthRuntime(configureCore: o => + { + o.Login.MaxFailedAttempts = 3; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "user", // valid password + }); + + var factory = runtime.Services.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state?.FailedAttempts.Should().Be(0); + } + + [Fact] + public async Task Invalid_password_should_fail_login() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Non_existent_user_should_fail_login_gracefully() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "ghost", + Secret = "whatever", + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task MaxFailedAttempts_one_should_lock_user_on_first_fail() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + var factory = runtime.Services.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + + state!.IsLocked(DateTimeOffset.UtcNow).Should().BeTrue(); + } + + [Fact] + public async Task Locked_user_should_not_login_even_with_correct_password() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + var result = await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "user", + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Locked_user_should_not_increment_failed_attempts() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + var factory = runtime.Services.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var state1 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + }); + + var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.FailedAttempts.Should().Be(state1!.FailedAttempts); + } + + [Fact] + public async Task MaxFailedAttempts_zero_should_disable_lockout() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 0; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + for (int i = 0; i < 5; i++) + { + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + } + + var factory = runtime.Services.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var state = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + + state?.IsLocked(DateTimeOffset.UtcNow).Should().BeFalse(); + state?.FailedAttempts.Should().Be(5); + } + + [Fact] + public async Task Locked_user_failed_login_should_not_extend_lockout_duration() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Login.MaxFailedAttempts = 1; + o.Login.LockoutDuration = TimeSpan.FromMinutes(15); + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + var factory = runtime.Services.GetRequiredService(); + var store = factory.Create(TenantKeys.Single); + var state1 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + + var lockedUntil = state1!.LockedUntil; + + await orchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "user", + Secret = "wrong", + }); + + var state2 = await store.GetAsync(TestUsers.User, AuthenticationSecurityScope.Factor, CredentialType.Password); + state2?.LockedUntil.Should().Be(lockedUntil); + } + + [Fact] + public async Task Login_success_should_trigger_UserLoggedIn_event() + { + UserLoggedInContext? captured = null; + + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Events.OnUserLoggedIn = ctx => + { + captured = ctx; + return Task.CompletedTask; + }; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user", + }); + + captured.Should().NotBeNull(); + captured!.UserKey.Should().Be(TestUsers.User); + } + + [Fact] + public async Task Login_success_should_trigger_OnAnyEvent() + { + var count = 0; + + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Events.OnAnyEvent = _ => + { + count++; + return Task.CompletedTask; + }; + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user", + }); + + count.Should().BeGreaterThan(0); + } + + [Fact] + public async Task Event_handler_exception_should_not_break_login_flow() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Events.OnUserLoggedIn = _ => throw new Exception("boom"); + }); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user", + }); + + result.IsSuccess.Should().BeTrue(); + } + + [Fact] + public async Task Login_With_Execution_Context_Should_Create_Session_For_Overridden_Device() + { + var runtime = new TestAuthRuntime(); + + using var scope = runtime.Services.CreateScope(); + + var flowService = scope.ServiceProvider.GetRequiredService(); + var sessionStoreFactory = scope.ServiceProvider.GetRequiredService(); + + var flow = await runtime.CreateLoginFlowAsync(); + var overriddenDevice = TestDevice.Alternative(); + + var execution = new AuthExecutionContext + { + EffectiveClientProfile = UAuthClientProfile.BlazorWasm, + Device = overriddenDevice + }; + + var result = await flowService.LoginAsync( + flow, + execution, + new LoginRequest + { + Identifier = "user", + Secret = "user" + }, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.SessionId.Should().NotBeNull(); + + var sessionStore = sessionStoreFactory.Create(TenantKeys.Single); + var session = await sessionStore.GetSessionAsync(result.SessionId!.Value); + + session.Should().NotBeNull(); + session!.Device.DeviceId.Should().Be(overriddenDevice.DeviceId); + + var chainStore = sessionStoreFactory.Create(TenantKeys.Single); + var chain = await chainStore.GetChainByDeviceAsync(TestUsers.User, (DeviceId)overriddenDevice.DeviceId!); + + chain.Should().NotBeNull(); + chain!.Device.DeviceId.Should().Be(overriddenDevice.DeviceId); + } + + [Fact] + public async Task Internal_Login_Should_Respect_LoginExecutionOptions() + { + var runtime = new TestAuthRuntime(); + + using var scope = runtime.Services.CreateScope(); + + var service = scope.ServiceProvider.GetRequiredService(); + var flow = await runtime.CreateLoginFlowAsync(); + + var options = new LoginExecutionOptions + { + + }; + + var result = await service.LoginAsync( + flow, + new LoginRequest + { + Identifier = "user", + Secret = "user" + }, + options); + + result.IsSuccess.Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs new file mode 100644 index 00000000..702308a5 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/PkceTests.cs @@ -0,0 +1,129 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Server.Stores; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class PkceTests +{ + [Fact] + public void Pkce_Should_Succeed_With_Valid_Data() + { + var validator = new PkceAuthorizationValidator(); + var (artifact, verifier) = TestPkceFactory.Create(); + var result = validator.Validate(artifact, verifier, artifact.Context, DateTimeOffset.UtcNow); + + result.Success.Should().BeTrue(); + } + + [Fact] + public void Pkce_Should_Fail_With_Invalid_Verifier() + { + var validator = new PkceAuthorizationValidator(); + var (artifact, _) = TestPkceFactory.Create(); + var result = validator.Validate(artifact, "wrong_verifier", artifact.Context, DateTimeOffset.UtcNow); + + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be(PkceValidationFailureReason.InvalidVerifier); + } + + [Fact] + public void Pkce_Should_Fail_On_Device_Mismatch() + { + var validator = new PkceAuthorizationValidator(); + var (artifact, verifier) = TestPkceFactory.Create(); + + var wrongContext = new PkceContextSnapshot( + artifact.Context.ClientProfile, + artifact.Context.Tenant, + artifact.Context.RedirectUri, + device: TestDevice.Alternative() + ); + + var result = validator.Validate(artifact, verifier, wrongContext, DateTimeOffset.UtcNow); + + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be(PkceValidationFailureReason.ContextMismatch); + } + + [Fact] + public async Task Refresh_Should_Generate_New_AuthorizationCode() + { + var runtime = new TestAuthRuntime(); + + using var scope = runtime.Services.CreateScope(); + + var store = scope.ServiceProvider.GetRequiredService(); + var pkceService = scope.ServiceProvider.GetRequiredService(); + + var (artifact, _) = TestPkceFactory.Create(); + + await store.StoreAsync(artifact.AuthorizationCode, artifact); + + var hub = TestHubFactory.Create(artifact); + + var refreshed = await pkceService.RefreshAsync(hub); + + refreshed.AuthorizationCode.Should().NotBe(artifact.AuthorizationCode.Value); + } + + [Fact] + public async Task Refresh_Should_Store_New_PkceArtifact() + { + var runtime = new TestAuthRuntime(); + + using var scope = runtime.Services.CreateScope(); + + var store = scope.ServiceProvider.GetRequiredService(); + var pkceService = scope.ServiceProvider.GetRequiredService(); + + var (artifact, _) = TestPkceFactory.Create(); + + await store.StoreAsync(artifact.AuthorizationCode, artifact); + + var hub = TestHubFactory.Create(artifact); + + var refreshed = await pkceService.RefreshAsync(hub); + + var stored = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode)); + + stored.Should().NotBeNull(); + } + + [Fact] + public async Task Refresh_Should_Invalidate_Old_Code() + { + var runtime = new TestAuthRuntime(); + using var scope = runtime.Services.CreateScope(); + var store = scope.ServiceProvider.GetRequiredService(); + var pkceService = scope.ServiceProvider.GetRequiredService(); + + var (artifact, _) = TestPkceFactory.Create(); + await store.StoreAsync(artifact.AuthorizationCode, artifact); + var hub = TestHubFactory.Create(artifact); + var refreshed = await pkceService.RefreshAsync(hub); + var old = await store.GetAsync(artifact.AuthorizationCode); + + old.Should().BeNull(); + } + + [Fact] + public async Task Refresh_Should_Preserve_Context() + { + var runtime = new TestAuthRuntime(); + using var scope = runtime.Services.CreateScope(); + var pkceService = scope.ServiceProvider.GetRequiredService(); + + var (artifact, _) = TestPkceFactory.Create(); + var hub = TestHubFactory.Create(artifact); + var refreshed = await pkceService.RefreshAsync(hub); + + var store = scope.ServiceProvider.GetRequiredService(); + var newArtifact = await store.GetAsync(new AuthArtifactKey(refreshed.AuthorizationCode)) as PkceAuthorizationArtifact; + + newArtifact!.Context.Device.Should().BeEquivalentTo(artifact.Context.Device); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs new file mode 100644 index 00000000..76c39738 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/RedirectTests.cs @@ -0,0 +1,229 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Defaults; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using System.Text; +using System.Text.Json; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class RedirectTests +{ + [Fact] + public void LoginFlow_Uses_Configured_Redirect_Options() + { + var services = new ServiceCollection(); + + services.AddOptions(); + services.Configure(o => + { + o.AuthResponse.Login.AllowReturnUrlOverride = false; + o.AuthResponse.Login.SuccessRedirect = "/welcome"; + }); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + var optionsProvider = provider.GetRequiredService(); + var resolver = provider.GetRequiredService(); + + var effective = optionsProvider.GetEffective(TenantKey.Single, AuthFlowType.Login, UAuthClientProfile.BlazorServer); + var response = resolver.Resolve(effective.Mode, AuthFlowType.Login, UAuthClientProfile.BlazorServer, effective); + + response.Redirect.AllowReturnUrlOverride.Should().BeFalse(); + response.Redirect.SuccessPath.Should().Be("/welcome"); + } + + [Fact] + public async Task ClientProfile_Is_Read_From_Header() + { + var reader = new ClientProfileReader(); + var ctx = TestHttpContext.Create(); + ctx.Request.Headers[UAuthConstants.Headers.ClientProfile] = "BlazorServer"; + + var profile = await reader.ReadAsync(ctx); + profile.Should().Be(UAuthClientProfile.BlazorServer); + } + + [Theory] + [InlineData(UAuthClientProfile.BlazorWasm, AuthFlowType.Login, UAuthMode.Hybrid)] + [InlineData(UAuthClientProfile.BlazorServer, AuthFlowType.Login, UAuthMode.PureOpaque)] + public void ClientProfile_Resolves_To_Correct_Mode(UAuthClientProfile profile, AuthFlowType flow, UAuthMode expected) + { + var resolver = new EffectiveAuthModeResolver(); + var mode = resolver.Resolve(profile, flow); + mode.Should().Be(expected); + } + + [Fact] + public void Absolute_ReturnUrl_Is_Used_When_Override_Allowed() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("https://app.example.com/dashboard"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureCodes: null, + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveSuccess(flow, ctx); + decision.Enabled.Should().BeTrue(); + decision.TargetUrl.Should().Be("https://app.example.com/dashboard"); + } + + [Fact] + public void Absolute_ReturnUrl_Is_Ignored_When_Override_Disabled() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("https://app.example.com/dashboard"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureCodes: null, + allowReturnUrlOverride: false, + includeLockoutTiming: true, + includeRemainingAttempts: false + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveSuccess(flow, ctx); + decision.TargetUrl.Should().Be("https://app.example.com/welcome"); + } + + [Fact] + public void Relative_ReturnUrl_Is_Combined_With_BaseAddress() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("/dashboard"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureCodes: null, + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); // https://app.example.com + + var decision = resolver.ResolveSuccess(flow, ctx); + decision.TargetUrl.Should().Be("https://app.example.com/dashboard"); + } + + [Fact] + public void SuccessPath_Is_Used_When_No_ReturnUrl() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: null, + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureCodes: null, + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + var decision = resolver.ResolveSuccess(flow, ctx); + decision.TargetUrl.Should().Be("https://app.example.com/welcome"); + } + + [Fact] + public void Absolute_ReturnUrl_Outside_AllowedOrigins_Throws() + { + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: ReturnUrlParser.Parse("https://evil.com"), + redirect: new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: null, + failureCodes: null, + allowReturnUrlOverride: true, + includeLockoutTiming: true, + includeRemainingAttempts: false + ) + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + flow.OriginalOptions.Hub.AllowedClientOrigins.Add("https://app.example.com"); + Action act = () => resolver.ResolveSuccess(flow, ctx); + act.Should().Throw().WithMessage("*not allowed*"); + } + + [Fact] + public void Failure_Redirect_Contains_Payload_With_Mapped_Error_Code() + { + var redirect = new EffectiveRedirectResponse( + enabled: true, + successPath: "/welcome", + failurePath: "/login", + failureCodes: new Dictionary + { + [AuthFailureReason.InvalidCredentials] = "bad_credentials" + }, + allowReturnUrlOverride: false, + includeLockoutTiming: true, + includeRemainingAttempts: true + ); + + var flow = AuthFlowTestFactory.LoginSuccess( + returnUrlInfo: null, + redirect: redirect + ); + + var resolver = TestRedirectResolver.Create(); + var ctx = TestHttpContext.Create(); + + var decision = resolver.ResolveFailure(flow, ctx, AuthFailureReason.InvalidCredentials); + + decision.Enabled.Should().BeTrue(); + decision.TargetUrl.Should().NotBeNull(); + + var uri = new Uri(decision.TargetUrl!); + var query = QueryHelpers.ParseQuery(uri.Query); + + query.Should().ContainKey("uauth"); + + var encoded = query["uauth"].ToString(); + var bytes = WebEncoders.Base64UrlDecode(encoded); + var json = Encoding.UTF8.GetString(bytes); + + var payload = JsonSerializer.Deserialize(json); + + payload.Should().NotBeNull(); + payload!.Flow.Should().Be(AuthFlowType.Login); + payload.Status.Should().Be("failed"); + payload.Reason.Should().Be(AuthFailureReason.InvalidCredentials); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs new file mode 100644 index 00000000..e3968775 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ReturnUrlParserTests.cs @@ -0,0 +1,45 @@ +๏ปฟusing CodeBeam.UltimateAuth.Server.Auth; +using CodeBeam.UltimateAuth.Server.Infrastructure; +using CodeBeam.UltimateAuth.Server.Options; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public sealed class ReturnUrlParserTests +{ + [Fact] + public void Relative_Path_Is_Not_Treated_As_Absolute_Uri() + { + var input = "/dashboard"; + var info = ReturnUrlParser.Parse(input); + + info.Should().NotBeNull(); + info!.IsAbsolute.Should().BeFalse(); + info.RelativePath.Should().Be("/dashboard"); + info.AbsoluteUri.Should().BeNull(); + } + + [Fact] + public void Absolute_Https_Url_Is_Treated_As_Absolute() + { + var input = "https://app.example.com/dashboard"; + var info = ReturnUrlParser.Parse(input); + + info.Should().NotBeNull(); + info!.IsAbsolute.Should().BeTrue(); + info.AbsoluteUri!.ToString().Should().Be(input); + info.RelativePath.Should().BeNull(); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("data:text/html;base64,AAA")] + [InlineData("file:///etc/passwd")] + [InlineData("ftp://evil.com")] + public void Parser_Rejects_Unsafe_Schemes(string returnUrl) + { + Action act = () => ReturnUrlParser.Parse(returnUrl); + act.Should().Throw().WithMessage("*Invalid returnUrl*"); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs new file mode 100644 index 00000000..90b20a6f --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/ServerOptionsValidatorTests.cs @@ -0,0 +1,362 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Extensions; +using CodeBeam.UltimateAuth.Core.Options; +using CodeBeam.UltimateAuth.Server.Extensions; +using CodeBeam.UltimateAuth.Server.Options; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class ServerOptionsValidatorTests +{ + [Fact] + public void Server_session_options_with_negative_idle_timeout_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Session.IdleTimeout = TimeSpan.FromSeconds(-5); + }); + + services.AddSingleton, UAuthServerSessionOptionsValidator>(); + + services.AddOptions().ValidateOnStart(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*Session.IdleTimeout*"); + } + + [Fact] + public void Valid_server_session_options_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Session.Lifetime = TimeSpan.FromMinutes(30); + o.Session.IdleTimeout = TimeSpan.FromMinutes(10); + }); + + services.AddSingleton, UAuthServerSessionOptionsValidator>(); + + services.AddOptions().ValidateOnStart(); + + var provider = services.BuildServiceProvider(); + + provider.Should().NotBeNull(); + } + + [Fact] + public void Server_token_options_with_small_opaque_id_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Token.IssueOpaque = true; + o.Token.OpaqueIdBytes = 8; + }); + + services.AddSingleton, UAuthServerTokenOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*OpaqueIdBytes*"); + } + + [Fact] + public void Valid_server_token_options_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddUltimateAuth(); + services.AddUltimateAuthServer(o => + { + o.Token.IssueJwt = true; + o.Token.IssueOpaque = true; + o.Token.AccessTokenLifetime = TimeSpan.FromMinutes(5); + o.Token.RefreshTokenLifetime = TimeSpan.FromDays(1); + o.Token.OpaqueIdBytes = 32; + }); + + services.AddSingleton, UAuthServerTokenOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + options.Should().NotBeNull(); + } + + [Fact] + public void Pkce_authorization_code_lifetime_must_be_positive() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.Pkce.AuthorizationCodeLifetimeSeconds = 0; + }); + + services.AddSingleton, UAuthServerPkceOptionsValidator>(); + var provider = services.BuildServiceProvider(); + + var ex = Assert.Throws(() => + { + _ = provider.GetRequiredService>().Value; + }); + + Assert.Contains("Pkce.AuthorizationCodeLifetimeSeconds must be > 0", ex.Message); + } + + [Fact] + public void MultiTenant_enabled_without_resolver_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableRoute = false; + o.MultiTenant.EnableHeader = false; + o.MultiTenant.EnableDomain = false; + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*no tenant resolver is active*"); + } + + [Fact] + public void MultiTenant_disabled_with_resolver_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = false; + o.MultiTenant.EnableRoute = true; // no-meaning if multi-tenancy is disabled + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*Multi-tenancy is disabled*"); + } + + [Fact] + public void Header_enabled_without_header_name_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableHeader = true; + o.MultiTenant.HeaderName = ""; + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*HeaderName must be specified*"); + } + + [Fact] + public void Valid_multi_tenant_route_only_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.MultiTenant.Enabled = true; + o.MultiTenant.EnableRoute = true; + o.MultiTenant.EnableHeader = false; + o.MultiTenant.EnableDomain = false; + }); + + services.AddSingleton, UAuthServerMultiTenantOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + options.MultiTenant.Enabled.Should().BeTrue(); + options.MultiTenant.EnableRoute.Should().BeTrue(); + } + + [Fact] + public void UserIdentifiers_both_admin_and_user_override_disabled_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.Identifiers.AllowAdminOverride = false; + o.Identifiers.AllowUserOverride = false; + }); + + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + + var provider = services.BuildServiceProvider(); + + Action act = () => + { + _ = provider.GetRequiredService>().Value; + }; + + act.Should().Throw().WithMessage("*AllowAdminOverride and AllowUserOverride*"); + } + + [Fact] + public void UserIdentifiers_at_least_one_override_enabled_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.Identifiers.AllowAdminOverride = true; + o.Identifiers.AllowUserOverride = false; + }); + + services.AddSingleton, UAuthServerUserIdentifierOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.Identifiers.AllowAdminOverride.Should().BeTrue(); + } + + [Fact] + public void No_session_resolver_enabled_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableBearer = false; + o.SessionResolution.EnableHeader = false; + o.SessionResolution.EnableCookie = false; + o.SessionResolution.EnableQuery = false; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*At least one session resolver must be enabled*"); + } + + [Fact] + public void Disabled_resolver_in_order_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.EnableQuery = false; + o.SessionResolution.Order = new() { "Bearer", "Query" }; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*not enabled*"); + } + + [Fact] + public void Header_enabled_without_name_should_fail() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableHeader = true; + o.SessionResolution.HeaderName = ""; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + Action act = () => _ = provider.GetRequiredService>().Value; + act.Should().Throw().WithMessage("*HeaderName*"); + } + + [Fact] + public void Valid_session_resolution_should_pass() + { + var services = new ServiceCollection(); + services.AddSingleton(new ConfigurationBuilder().AddInMemoryCollection().Build()); + + services.AddOptions() + .Configure(o => + { + o.SessionResolution.EnableBearer = true; + o.SessionResolution.EnableHeader = false; + o.SessionResolution.EnableCookie = false; + o.SessionResolution.EnableQuery = false; + o.SessionResolution.Order = new() { "Bearer" }; + }); + + services.AddSingleton, UAuthServerSessionResolutionOptionsValidator>(); + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + options.SessionResolution.EnableBearer.Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs new file mode 100644 index 00000000..e337339b --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Server/TryLoginTests.cs @@ -0,0 +1,36 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Server.Flows; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using System; +using System.Collections.Generic; +using System.Text; + +namespace CodeBeam.UltimateAuth.Tests.Unit.Server +{ + public class TryLoginTests + { + [Fact] + public async Task TryLogin_Should_Return_Preview_On_Failure() + { + var runtime = new TestAuthRuntime(); + + using var scope = runtime.Services.CreateScope(); + + var flow = await runtime.CreateLoginFlowAsync(); + var orchestrator = scope.ServiceProvider.GetRequiredService(); + + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "wrong" + }); + + result.Should().NotBeNull(); + result.IsSuccess.Should().BeFalse(); + + result.RemainingAttempts.Should().NotBeNull(); + } + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs new file mode 100644 index 00000000..0011259e --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/SessionTests.cs @@ -0,0 +1,133 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Abstractions; +using CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Server.Services; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class SessionTests +{ + [Fact] + public async Task Login_should_cleanup_old_sessions_when_limit_exceeded() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Session.MaxSessionsPerChain = 3; + }); + + var flow = await runtime.CreateLoginFlowAsync(); + for (int i = 0; i < 5; i++) + { + await runtime.LoginAsync(flow); + } + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + var chain = chains.First(); + var sessions = await store.GetSessionsByChainAsync(chain.ChainId); + + sessions.Count.Should().BeLessThanOrEqualTo(3); + } + + [Fact] + public async Task Logout_device_should_revoke_sessions_but_keep_chain() + { + var runtime = new TestAuthRuntime(); + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var result = await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user" + }); + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chainId = await store.GetChainIdBySessionAsync(result.SessionId!.Value); + await store.LogoutChainAsync(chainId!.Value, runtime.Clock.UtcNow); + var chain = await store.GetChainAsync(chainId.Value); + + chain.Should().NotBeNull(); + chain!.ActiveSessionId.Should().BeNull(); + + var sessions = await store.GetSessionsByChainAsync(chainId.Value); + + sessions.All(x => x.IsRevoked).Should().BeTrue(); + } + + [Fact] + public async Task Logout_other_devices_should_keep_current_chain() + { + var runtime = new TestAuthRuntime(); + + var flow1 = await runtime.CreateLoginFlowAsync(); + var flow2 = await runtime.CreateLoginFlowAsync(); + + await runtime.LoginAsync(flow1); + + await runtime.LoginAsync(flow2); + + var store = runtime.Services.GetRequiredService() + .Create(TenantKey.Single); + + var chains = await store.GetChainsByUserAsync(TestUsers.User); + + var current = chains.First(); + + await store.RevokeOtherSessionsAsync(current.UserKey, current.ChainId, runtime.Clock.UtcNow); + + var updatedChains = await store.GetChainsByUserAsync(current.UserKey); + + updatedChains.Count(x => x.State == SessionChainState.Active).Should().Be(1); + } + + [Fact] + public async Task Get_chain_detail_should_return_sessions() + { + var runtime = new TestAuthRuntime(); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user" + }); + + var service = runtime.Services.GetRequiredService(); + var context = TestAccessContext.ForUser(TestUsers.User, "session.query"); + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + var result = await service.GetUserChainDetailAsync(context, chains.First().UserKey, chains.First().ChainId); + + result.Should().NotBeNull(); + result.Sessions.Should().NotBeEmpty(); + } + + [Fact] + public async Task Revoke_chain_should_revoke_all_sessions() + { + var runtime = new TestAuthRuntime(); + + var orchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + await orchestrator.LoginAsync(flow, new LoginRequest + { + Identifier = "user", + Secret = "user" + }); + + var store = runtime.Services.GetRequiredService().Create(TenantKey.Single); + var chains = await store.GetChainsByUserAsync(TestUsers.User); + await store.RevokeChainCascadeAsync(chains.First().ChainId, runtime.Clock.UtcNow); + var sessions = await store.GetSessionsByChainAsync(chains.First().ChainId); + + sessions.All(x => x.IsRevoked).Should().BeTrue(); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs new file mode 100644 index 00000000..ef600fd8 --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/IdentifierConcurrencyTests.cs @@ -0,0 +1,355 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.InMemory; +using CodeBeam.UltimateAuth.Users.Reference; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class IdentifierConcurrencyTests +{ + [Fact] + public async Task Save_should_increment_version() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + + copy.ChangeValue("b@test.com", "b@test.com", now); + await store.SaveAsync(copy, expected); + + var updated = await store.GetByIdAsync(id); + + Assert.Equal(expected + 1, updated!.Version); + } + + [Fact] + public async Task Delete_should_throw_when_version_conflicts() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + var copy1 = await store.GetByIdAsync(id); + var copy2 = await store.GetByIdAsync(id); + + var expected1 = copy1!.Version; + copy1.MarkDeleted(now); + await store.SaveAsync(copy1, expected1); + + await Assert.ThrowsAsync(async () => + { + await store.DeleteAsync(copy2!.Id, copy2!.Version, DeleteMode.Soft, now); + }); + } + + [Fact] + public async Task Parallel_SetPrimary_should_conflict_deterministic() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = Enumerable.Range(0, 2) + .Select(_ => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(1, conflicts); + + var final = await store.GetByIdAsync(id); + Assert.True(final!.IsPrimary); + Assert.Equal(1, final.Version); + } + + [Fact] + public async Task Update_should_throw_concurrency_when_versions_conflict() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var id = Guid.NewGuid(); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + var copy1 = await store.GetByIdAsync(id); + var copy2 = await store.GetByIdAsync(id); + + var expected1 = copy1!.Version; + copy1.ChangeValue("b@test.com", "b@test.com", now); + await store.SaveAsync(copy1, expected1); + + var expected2 = copy2!.Version; + copy2.ChangeValue("c@test.com", "c@test.com", now); + + await Assert.ThrowsAsync(async () => + { + await store.SaveAsync(copy2, expected2); + }); + } + + [Fact] + public async Task Parallel_updates_should_result_in_single_success_deterministic() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = Enumerable.Range(0, 2) + .Select(i => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + barrier.SignalAndWait(); + var expected = copy!.Version; + + var newValue = i == 0 + ? "x@test.com" + : "y@test.com"; + + copy.ChangeValue(newValue, newValue, now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(1, conflicts); + + var final = await store.GetByIdAsync(id); + + Assert.NotNull(final); + Assert.Equal(1, final!.Version); + } + + [Fact] + public async Task High_contention_updates_should_allow_only_one_success() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "initial@test.com", "initial@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var tasks = Enumerable.Range(0, 20) + .Select(i => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + var expected = copy!.Version; + + var newValue = $"user{i}@test.com"; + + copy.ChangeValue(newValue, newValue, now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.True(success >= 1); + Assert.Equal(20, success + conflicts); + + var final = await store.GetByIdAsync(id); + Assert.Equal(success, final!.Version); + } + + [Fact] + public async Task High_contention_SetPrimary_should_allow_only_one_deterministic() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + + var id = Guid.NewGuid(); + + var identifier = UserIdentifier.Create(id, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "primary@test.com", "primary@test.com", now); + await store.AddAsync(identifier); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(20); + + var tasks = Enumerable.Range(0, 20) + .Select(_ => Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + await store.SaveAsync(copy, expected); + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + })) + .ToArray(); + + await Task.WhenAll(tasks); + + Assert.Equal(1, success); + Assert.Equal(19, conflicts); + + var final = await store.GetByIdAsync(id); + + Assert.True(final!.IsPrimary); + Assert.Equal(1, final.Version); + } + + [Fact] + public async Task Two_identifiers_racing_for_primary_should_allow() + { + var store = new InMemoryUserIdentifierStore(new TenantContext(TenantKeys.Single)); + var now = DateTimeOffset.UtcNow; + var tenant = TenantKey.Single; + var user = TestUsers.Admin; + + var id1 = Guid.NewGuid(); + var id2 = Guid.NewGuid(); + + var identifier1 = UserIdentifier.Create(id1, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "a@test.com", "a@test.com", now); + var identifier2 = UserIdentifier.Create(id2, TenantKey.Single, TestUsers.Admin, UserIdentifierType.Email, "b@test.com", "b@test.com", now); + + await store.AddAsync(identifier1); + await store.AddAsync(identifier2); + + int success = 0; + int conflicts = 0; + + var barrier = new Barrier(2); + + var tasks = new[] + { + Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id1); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }), + Task.Run(async () => + { + try + { + var copy = await store.GetByIdAsync(id2); + + barrier.SignalAndWait(); + + var expected = copy!.Version; + copy.SetPrimary(now); + + await store.SaveAsync(copy, expected); + + Interlocked.Increment(ref success); + } + catch (UAuthConcurrencyException) + { + Interlocked.Increment(ref conflicts); + } + }) + }; + + await Task.WhenAll(tasks); + + Assert.Equal(2, success); + Assert.Equal(0, conflicts); + + var all = await store.GetByUserAsync(user); + + var primaries = all + .Where(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + .ToList(); + + Assert.Single(primaries); + } +} diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs new file mode 100644 index 00000000..7122902a --- /dev/null +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Users/UserIdentifierApplicationServiceTests.cs @@ -0,0 +1,379 @@ +๏ปฟusing CodeBeam.UltimateAuth.Core.Contracts; +using CodeBeam.UltimateAuth.Core.Domain; +using CodeBeam.UltimateAuth.Core.Errors; +using CodeBeam.UltimateAuth.Core.MultiTenancy; +using CodeBeam.UltimateAuth.Tests.Unit.Helpers; +using CodeBeam.UltimateAuth.Users.Contracts; +using CodeBeam.UltimateAuth.Users.Reference; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using static CodeBeam.UltimateAuth.Core.Defaults.UAuthActions; + +namespace CodeBeam.UltimateAuth.Tests.Unit; + +public class UserIdentifierApplicationServiceTests +{ + [Fact] + public async Task Adding_new_primary_email_should_replace_existing_primary() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "new@example.com", + IsPrimary = true + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + identifiers.Items.Where(x => x.Type == UserIdentifierType.Email).Should().ContainSingle(x => x.IsPrimary); + + identifiers.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary).Value.Should().Be("new@example.com"); + } + + [Fact] + public async Task Primary_phone_should_not_be_login_if_not_allowed() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.AllowedTypes = new HashSet + { + UserIdentifierType.Email + }; + }); + + var identifierService = runtime.Services.GetRequiredService(); + var loginOrchestrator = runtime.GetLoginOrchestrator(); + var flow = await runtime.CreateLoginFlowAsync(); + + var context = TestAccessContext.ForUser( + TestUsers.User, + action: "users.identifiers.add"); + + await identifierService.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+905551111111", + IsPrimary = true + }); + + var result = await loginOrchestrator.LoginAsync(flow, + new LoginRequest + { + Identifier = "+905551111111", + Secret = "user", + }); + + result.IsSuccess.Should().BeFalse(); + } + + [Fact] + public async Task Adding_non_primary_should_not_affect_existing_primary() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + var before = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var existingPrimaryEmail = before.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "secondary@example.com", + IsPrimary = false + }); + + var after = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + after.Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary) + .Id.Should().Be(existingPrimaryEmail.Id); + } + + [Fact] + public async Task Updating_value_should_reset_verification() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UpdateSelf); + + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email); + + await service.UpdateUserIdentifierAsync(context, + new UpdateUserIdentifierRequest + { + Id = email.Id, + NewValue = "updated@example.com" + }); + + var updated = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items + .Single(x => x.Id == email.Id); + + updated.IsVerified.Should().BeFalse(); + } + + [Fact] + public async Task Non_primary_duplicate_should_be_allowed_when_global_uniqueness_disabled() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(user1, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + await service.AddUserIdentifierAsync(user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "shared@example.com", + IsPrimary = false + }); + + true.Should().BeTrue(); // no exception + } + + [Fact] + public async Task Primary_duplicate_should_fail_when_global_uniqueness_enabled() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.EnforceGlobalUniquenessForAllIdentifiers = true; + }); + + var service = runtime.GetUserApplicationService(); + + var user1 = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + var user2 = TestAccessContext.ForUser(TestUsers.Admin, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(user1, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com", + IsPrimary = true + }); + + Func act = async () => + await service.AddUserIdentifierAsync(user2, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "unique@example.com", + IsPrimary = true + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Unsetting_last_login_identifier_should_fail() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.AllowedTypes = new HashSet + { + UserIdentifierType.Email + }; + }); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.UnsetPrimarySelf); + + var email = (await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery())).Items.Single(x => x.Type == UserIdentifierType.Email && x.IsPrimary); + + Func act = async () => + await service.UnsetPrimaryUserIdentifierAsync(context, + new UnsetPrimaryUserIdentifierRequest + { + Id = email.Id + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Email_should_be_case_insensitive_by_default() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "Test@Example.com" + }); + + Func act = async () => + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "test@example.com" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Username_should_respect_case_policy() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.Identifiers.AllowMultipleUsernames = true; + }); + + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "UserName" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + identifiers.Items.Should().Contain(x => x.Value == "UserName"); + } + + [Fact] + public async Task Username_should_be_case_insensitive_when_configured() + { + var runtime = new TestAuthRuntime(configureServer: o => + { + o.LoginIdentifiers.Normalization.UsernameCase = CaseHandling.ToLower; + o.Identifiers.AllowMultipleUsernames = true; + }); + + var service = runtime.GetUserApplicationService(); + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "UserName" + }); + + Func act = async () => + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Username, + Value = "username" + }); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Phone_should_be_normalized_to_digits() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Phone, + Value = "+90 (555) 123-45-67" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + + identifiers.Items.Should().Contain(x => + x.Type == UserIdentifierType.Phone && + x.Value == "+90 (555) 123-45-67"); + } + + [Fact] + public async Task Updating_to_existing_value_should_fail() + { + var runtime = new TestAuthRuntime(); + var service = runtime.GetUserApplicationService(); + + var context = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "one@example.com" + }); + + await service.AddUserIdentifierAsync(context, + new AddUserIdentifierRequest + { + Type = UserIdentifierType.Email, + Value = "two@example.com" + }); + + var identifiers = await service.GetIdentifiersByUserAsync(context, new UserIdentifierQuery()); + var second = identifiers.Items.Single(x => x.Value == "two@example.com"); + + Func act = async () => + await service.UpdateUserIdentifierAsync(context, + new UpdateUserIdentifierRequest + { + Id = second.Id, + NewValue = "one@example.com" + }); + + await act.Should().ThrowAsync(); + } + + //[Fact] + //public async Task Same_identifier_in_different_tenants_should_not_conflict() + //{ + // var runtime = new TestAuthRuntime(); + + // var service = runtime.GetUserApplicationService(); + + // var tenant1User = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf, TenantKey.Single); + // var tenant2User = TestAccessContext.ForUser(TestUsers.User, UserIdentifiers.AddSelf, TenantKey.FromInternal("other")); + + // await service.AddUserIdentifierAsync(tenant1User, + // new AddUserIdentifierRequest + // { + // Type = UserIdentifierType.Email, + // Value = "tenant@example.com", + // IsPrimary = true + // }); + + // await service.AddUserIdentifierAsync(tenant2User, + // new AddUserIdentifierRequest + // { + // Type = UserIdentifierType.Email, + // Value = "tenant@example.com", + // IsPrimary = true + // }); + + // true.Should().BeTrue(); + //} +} +